Add Tracks support for the Onboarding flow

This commit is contained in:
Daniel Dudzic 2025-06-06 13:33:32 +02:00
parent b5c9a6f5f4
commit e7d9d6f400
No known key found for this signature in database
GPG key ID: 31B40D33E3465483
36 changed files with 3618 additions and 134 deletions

View file

@ -8,6 +8,8 @@ import OnboardingScreen from './Screens/Onboarding';
import SettingsScreen from './Screens/Settings';
import { getQuery, cleanUrlQueryParams } from '../utils/navigation';
import { initializeTracking } from '../services/tracking';
const SettingsApp = () => {
const { isReady: onboardingIsReady, completed: onboardingCompleted } =
OnboardingHooks.useSteps();
@ -16,6 +18,10 @@ const SettingsApp = () => {
merchant: { isSendOnlyCountry },
} = CommonHooks.useMerchantInfo();
useEffect( () => {
initializeTracking();
}, [] );
// Disable the "Changes you made might not be saved" browser warning.
useEffect( () => {
const suppressBeforeUnload = ( event ) => {

View file

@ -1,8 +1,9 @@
import { Button } from '@wordpress/components';
import { useEffect } from '@wordpress/element';
import { useEffect, useCallback } from '@wordpress/element';
import classNames from 'classnames';
import { OpenSignup } from '../../../ReusableComponents/Icons';
import { useHandleOnboardingButton } from '../../../../hooks/useHandleConnections';
import { OnboardingHooks } from '../../../../data/onboarding/hooks';
import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
/**
@ -10,12 +11,13 @@ import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
* placeholder button looks identical to the working button, but has no href, target, or
* custom connection attributes.
*
* @param {Object} props
* @param {string} props.className
* @param {string} props.variant
* @param {boolean} props.showIcon
* @param {?string} props.href
* @param {Element} props.children
* @param {Object} props
* @param {string} props.className
* @param {string} props.variant
* @param {boolean} props.showIcon
* @param {?string} props.href
* @param {Element} props.children
* @param {Function} props.onClick
*/
const ButtonOrPlaceholder = ( {
className,
@ -23,11 +25,13 @@ const ButtonOrPlaceholder = ( {
showIcon,
href,
children,
onClick,
} ) => {
const buttonProps = {
className,
variant,
icon: showIcon ? OpenSignup : null,
onClick,
};
if ( href ) {
@ -52,12 +56,28 @@ const ConnectionButton = ( {
setCompleteHandler,
removeCompleteHandler,
} = useHandleOnboardingButton( isSandbox );
const { connectionButtonClicked, setConnectionButtonClicked } =
OnboardingHooks.useConnectionButton();
const buttonClassName = classNames( 'ppcp-r-connection-button', className, {
'ppcp--mode-sandbox': isSandbox,
'ppcp--mode-live': ! isSandbox,
'ppcp--button-clicked': connectionButtonClicked,
} );
const environment = isSandbox ? 'sandbox' : 'production';
const handleButtonClick = useCallback( () => {
setConnectionButtonClicked( true );
}, [ setConnectionButtonClicked ] );
// Reset button clicked state when onboardingUrl becomes available.
useEffect( () => {
if ( onboardingUrl && connectionButtonClicked ) {
setConnectionButtonClicked( false );
}
}, [ onboardingUrl, connectionButtonClicked, setConnectionButtonClicked ] );
useEffect( () => {
if ( scriptLoaded && onboardingUrl ) {
window.PAYPAL.apps.Signup.render();
@ -82,6 +102,7 @@ const ConnectionButton = ( {
variant={ variant }
showIcon={ showIcon }
href={ onboardingUrl }
onClick={ handleButtonClick }
>
<span className="button-title">{ title }</span>
</ButtonOrPlaceholder>

View file

@ -17,6 +17,7 @@ import {
useSandboxConnection,
} from '../../../../hooks/useHandleConnections';
import { OnboardingHooks } from '../../../../data';
import { useManualConnection } from '../../../../data/common/hooks';
const FORM_ERRORS = {
noClientId: __(
@ -43,14 +44,19 @@ const ManualConnectionForm = () => {
manualClientSecret,
setManualClientSecret,
} = OnboardingHooks.useManualConnectionForm();
const {
handleDirectAuthentication,
isManualConnectionMode,
setManualConnectionMode,
} = useDirectAuthentication();
const { handleDirectAuthentication } = useDirectAuthentication();
const { isManualConnectionMode, setManualConnectionMode } =
useManualConnection();
const refClientId = useRef( null );
const refClientSecret = useRef( null );
const handleToggle = ( isEnabled ) => {
setManualConnectionMode( isEnabled, 'user' );
};
// Form data validation and sanitation.
const getManualConnectionDetails = useCallback( () => {
const checks = [
@ -148,7 +154,7 @@ const ManualConnectionForm = () => {
) }
description={ advancedUsersDescription }
isToggled={ !! isManualConnectionMode }
setToggled={ setManualConnectionMode }
setToggled={ handleToggle }
>
<DataStoreControl
__nextHasNoMarginBottom

View file

@ -8,6 +8,10 @@ import ConnectionButton from './ConnectionButton';
const SandboxConnectionForm = () => {
const { isSandboxMode, setSandboxMode } = useSandboxConnection();
const handleToggle = ( isEnabled ) => {
setSandboxMode( isEnabled, 'user' );
};
return (
<BusyStateWrapper>
<SettingsToggleBlock
@ -20,7 +24,7 @@ const SandboxConnectionForm = () => {
'woocommerce-paypal-payments'
) }
isToggled={ !! isSandboxMode }
setToggled={ setSandboxMode }
setToggled={ handleToggle }
>
<ConnectionButton
title={ __(

View file

@ -26,7 +26,10 @@ const StepBusiness = ( {} ) => {
return;
}
setIsCasualSeller( BUSINESS_TYPES.CASUAL_SELLER === businessChoice );
setIsCasualSeller(
BUSINESS_TYPES.CASUAL_SELLER === businessChoice,
'user'
);
}, [ businessChoice, setIsCasualSeller ] );
const { canUseSubscriptions } = OnboardingHooks.useFlags();

View file

@ -47,6 +47,10 @@ const StepPaymentMethods = () => {
},
];
const handleMethodChange = ( value ) => {
setOptionalMethods( value, 'user' );
};
return (
<div className="ppcp-r-page-optional-payment-methods">
<OnboardingHeader
@ -56,7 +60,7 @@ const StepPaymentMethods = () => {
<OptionSelector
multiSelect={ false }
options={ methodChoices }
onChange={ setOptionalMethods }
onChange={ handleMethodChange }
value={ optionalMethods }
/>

View file

@ -38,7 +38,7 @@ const StepProducts = () => {
};
initChoices();
}, [ canUseSubscriptions, optionState, products, setProducts ] );
}, [ canUseSubscriptions, isCasualSeller ] );
const handleChange = ( key, checked ) => {
const getNewValue = () => {
@ -48,7 +48,7 @@ const StepProducts = () => {
return products.filter( ( val ) => val !== key );
};
setProducts( getNewValue() );
setProducts( getNewValue(), 'user' );
};
const productChoicesFull = [
{

View file

@ -14,7 +14,6 @@ import { usePaymentConfig } from '../hooks/usePaymentConfig';
const StepWelcome = ( { setStep, currentStep } ) => {
const { storeCountry, ownBrandOnly } = CommonHooks.useWooSettings();
const { canUseCardPayments, canUseFastlane } = OnboardingHooks.useFlags();
const { isCasualSeller } = OnboardingHooks.useBusiness();
const { icons } = usePaymentConfig(
storeCountry,
@ -34,6 +33,11 @@ const StepWelcome = ( { setStep, currentStep } ) => {
'woocommerce-paypal-payments'
);
const handleActivatePayPal = () => {
const nextStep = currentStep + 1;
setStep( nextStep, 'user' );
};
return (
<div className="ppcp-r-page-welcome">
<OnboardingHeader
@ -56,7 +60,7 @@ const StepWelcome = ( { setStep, currentStep } ) => {
<Button
className="ppcp-r-button-activate-paypal"
variant="primary"
onClick={ () => setStep( currentStep + 1 ) }
onClick={ handleActivatePayPal }
>
{ __(
'Activate PayPal Payments',

View file

@ -19,8 +19,8 @@ const OnboardingScreen = () => {
} );
}
const handleNext = () => setStep( currentStep.nextStep );
const handlePrev = () => setStep( currentStep.prevStep );
const handleNext = () => setStep( currentStep.nextStep, 'user' );
const handlePrev = () => setStep( currentStep.prevStep, 'user' );
return (
<>

View file

@ -18,4 +18,7 @@ export default {
// Activity management (advanced solution that replaces the isBusy state).
START_ACTIVITY: 'ppcp/common/START_ACTIVITY',
STOP_ACTIVITY: 'ppcp/common/STOP_ACTIVITY',
// Tracking.
CLEAR_FIELD_SOURCE: 'ppcp/common/CLEAR_FIELD_SOURCE',
};

View file

@ -36,26 +36,34 @@ export const hydrate = ( payload ) => ( {
/**
* Generic transient-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setTransient = ( prop, value ) => ( {
export const setTransient = ( prop, value, source = '' ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { [ prop ]: value },
source,
fieldName: prop,
} );
/**
* Generic persistent-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setPersistent = ( prop, value ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
} );
export const setPersistent = ( prop, value, source = '' ) => {
return {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
source,
fieldName: prop,
};
};
/**
* Transient. Marks the onboarding details as "ready", i.e., fully initialized.
@ -78,19 +86,22 @@ export const setActiveModal = ( activeModal ) =>
* Persistent. Sets the sandbox mode on or off.
*
* @param {boolean} useSandbox
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setSandboxMode = ( useSandbox ) =>
setPersistent( 'useSandbox', useSandbox );
export const setSandboxMode = ( useSandbox, source ) => {
return setPersistent( 'useSandbox', useSandbox, source );
};
/**
* Persistent. Toggles the "Manual Connection" mode on or off.
*
* @param {boolean} useManualConnection
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setManualConnectionMode = ( useManualConnection ) =>
setPersistent( 'useManualConnection', useManualConnection );
export const setManualConnectionMode = ( useManualConnection, source ) =>
setPersistent( 'useManualConnection', useManualConnection, source );
/**
* Persistent. Changes the "webhooks" value.
@ -152,3 +163,14 @@ export const stopActivity = ( id ) => ( {
type: ACTION_TYPES.STOP_ACTIVITY,
payload: { id },
} );
/**
* Action creator to clear field source tracking.
*
* @param {string} fieldName - Name of the field to clear source for.
* @return {Action} The action.
*/
export const clearFieldSource = ( fieldName ) => ( {
type: ACTION_TYPES.CLEAR_FIELD_SOURCE,
payload: { fieldName },
} );

View file

@ -50,11 +50,6 @@ const useHooks = () => {
// Transient accessors.
const [ activeModal, setActiveModal ] = useTransient( 'activeModal' );
// Persistent accessors.
const [ isManualConnectionMode, setManualConnectionMode ] = usePersistent(
'useManualConnection'
);
// Read-only properties.
const wooSettings = select.wooSettings();
const features = select.features();
@ -68,10 +63,6 @@ const useHooks = () => {
return {
activeModal,
setActiveModal,
isManualConnectionMode,
setManualConnectionMode: ( state ) => {
return savePersistent( setManualConnectionMode, state );
},
authenticateWithCredentials,
authenticateWithOAuth,
wooSettings,
@ -102,14 +93,29 @@ export const useSandbox = () => {
return {
isSandboxMode,
setSandboxMode: ( state ) => {
setSandboxMode( state );
setSandboxMode: ( state, source ) => {
setSandboxMode( state, source );
return dispatch.persist();
},
onboardingUrl,
};
};
export const useManualConnection = () => {
const { dispatch, usePersistent } = useStoreData();
const [ isManualConnectionMode, setManualConnectionMode ] = usePersistent(
'useManualConnection'
);
return {
isManualConnectionMode,
setManualConnectionMode: ( state, source ) => {
setManualConnectionMode( state, source );
return dispatch.persist();
},
};
};
export const useProduction = () => {
const { dispatch } = useStoreData();
const { onboardingUrl } = dispatch;
@ -118,12 +124,10 @@ export const useProduction = () => {
};
export const useAuthentication = () => {
const {
isManualConnectionMode,
setManualConnectionMode,
authenticateWithCredentials,
authenticateWithOAuth,
} = useHooks();
const { authenticateWithCredentials, authenticateWithOAuth } = useHooks();
const { isManualConnectionMode, setManualConnectionMode } =
useManualConnection();
return {
isManualConnectionMode,

View file

@ -8,6 +8,9 @@ import * as thunkActions from './actions-thunk';
import * as hooks from './hooks';
import * as resolvers from './resolvers';
import { addStoreToFunnel } from '../../services/tracking';
import { ONBOARDING_FUNNEL_ID } from '../../services/tracking/init';
/**
* Initializes and registers the settings store with WordPress data layer.
* Combines custom controls with WordPress data controls.
@ -24,6 +27,9 @@ export const initStore = () => {
register( store );
// Add this store to the onboarding funnel.
addStoreToFunnel( STORE_NAME, ONBOARDING_FUNNEL_ID );
return Boolean( wp.data.select( STORE_NAME ) );
};

View file

@ -17,6 +17,7 @@ const defaultTransient = Object.freeze( {
activities: new Map(),
activeModal: '',
activeHighlight: '',
fieldSources: Object.freeze( {} ),
// Read only values, provided by the server via hydrate.
merchant: Object.freeze( {
@ -73,21 +74,76 @@ const defaultPersistent = Object.freeze( {
useManualConnection: false,
} );
// Reducer logic.
const updateFieldSources = ( currentSources, fieldName, source ) => {
if ( ! source ) {
return currentSources;
}
return {
...currentSources,
[ fieldName ]: {
source,
timestamp: Date.now(),
},
};
};
const clearFieldSource = ( currentSources, fieldName ) => {
const newSources = { ...currentSources };
delete newSources[ fieldName ];
return newSources;
};
// Reducer logic.
const [ changeTransient, changePersistent ] = createReducerSetters(
defaultTransient,
defaultPersistent
);
const commonReducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, action ) =>
changeTransient( state, action ),
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload, action ) => {
const newState = changeTransient( state, payload );
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, action ) =>
changePersistent( state, action ),
if ( action && action.source ) {
const fieldName = Object.keys( payload )[ 0 ];
[ ACTION_TYPES.RESET ]: ( state ) => {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
fieldName,
action.source
);
}
return newState;
},
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload, action ) => {
const newState = changePersistent( state, payload );
if ( action && action.source ) {
const fieldName = Object.keys( payload )[ 0 ];
newState.fieldSources = updateFieldSources(
newState.fieldSources,
fieldName,
action.source
);
}
return newState;
},
[ ACTION_TYPES.CLEAR_FIELD_SOURCE ]: ( state, payload ) => {
const newState = { ...state };
newState.fieldSources = clearFieldSource(
newState.fieldSources,
payload.fieldName
);
return newState;
},
[ ACTION_TYPES.RESET ]: ( state, payload, action ) => {
const cleanState = changeTransient(
changePersistent( state, defaultPersistent ),
defaultTransient
@ -99,37 +155,109 @@ const commonReducer = createReducer( defaultTransient, defaultPersistent, {
cleanState.features = { ...state.features };
cleanState.isReady = true;
if ( action && action.source ) {
cleanState.fieldSources = updateFieldSources(
cleanState.fieldSources,
'reset',
action.source
);
}
return cleanState;
},
[ ACTION_TYPES.START_ACTIVITY ]: ( state, payload ) => {
return changeTransient( state, {
[ ACTION_TYPES.START_ACTIVITY ]: ( state, payload, action ) => {
const newState = changeTransient( state, {
activities: new Map( state.activities ).set(
payload.id,
payload.description
),
} );
if ( action && action.source ) {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
'activity_' + payload.id,
action.source
);
}
return newState;
},
[ ACTION_TYPES.STOP_ACTIVITY ]: ( state, payload ) => {
[ ACTION_TYPES.STOP_ACTIVITY ]: ( state, payload, action ) => {
const newActivities = new Map( state.activities );
newActivities.delete( payload.id );
return changeTransient( state, { activities: newActivities } );
const newState = changeTransient( state, {
activities: newActivities,
} );
if ( action && action.source ) {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
'activity_stop_' + payload.id,
action.source
);
}
return newState;
},
// Instantly reset the merchant data and features before refreshing the details.
[ ACTION_TYPES.RESET_MERCHANT ]: ( state ) => ( {
...state,
merchant: Object.freeze( { ...defaultTransient.merchant } ),
features: Object.freeze( { ...defaultTransient.features } ),
} ),
[ ACTION_TYPES.RESET_MERCHANT ]: ( state, payload, action ) => {
const newState = {
...state,
merchant: Object.freeze( { ...defaultTransient.merchant } ),
features: Object.freeze( { ...defaultTransient.features } ),
};
[ ACTION_TYPES.SET_MERCHANT ]: ( state, payload ) => {
return changePersistent( state, { merchant: payload.merchant } );
if ( action && action.source ) {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
'reset_merchant',
action.source
);
}
return newState;
},
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
const newState = changePersistent( state, payload.data );
[ ACTION_TYPES.SET_MERCHANT ]: ( state, payload, action ) => {
const newState = changePersistent( state, {
merchant: payload.merchant,
} );
if ( action && action.source ) {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
'merchant',
action.source
);
}
return newState;
},
[ ACTION_TYPES.HYDRATE ]: ( state, payload, action ) => {
let newState = { ...state };
if ( action && action.source && payload.data ) {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
'hydrate',
action.source
);
Object.keys( payload.data ).forEach( ( fieldName ) => {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
fieldName,
action.source
);
} );
}
newState = changePersistent( newState, payload.data );
// Populate read-only properties.
[ 'wooSettings', 'merchant', 'features', 'webhooks' ].forEach(

View file

@ -32,6 +32,32 @@ export const getActivityList = ( state ) => {
return Object.fromEntries( activities );
};
/**
* Get the source information for a specific field.
* @param {Object} state - Store state
* @param {string} fieldName
*/
export const getFieldSource = ( state, fieldName ) => {
const fieldSources = state?.fieldSources || {};
const fieldSource = fieldSources[ fieldName ];
if ( ! fieldSource ) {
return null;
}
return fieldSource;
};
/**
* Get all field sources for debugging.
* @param {Object} state - Store state
* @return {Object} All field source information
*/
export const getAllFieldSources = ( state ) => {
const currentState = getState( state );
return currentState.fieldSources || {};
};
export const merchant = ( state ) => {
return getState( state ).merchant || EMPTY_OBJ;
};

View file

@ -8,6 +8,9 @@ import * as Todos from './todos';
import * as PayLaterMessaging from './pay-later-messaging';
import * as Features from './features';
// Initialize tracking funnels before any store initialization.
import '../services/tracking/init';
const stores = [
Onboarding,
Common,

View file

@ -13,7 +13,10 @@ export default {
RESET: 'ppcp/onboarding/RESET',
HYDRATE: 'ppcp/onboarding/HYDRATE',
// Gateway sync flag
// Gateway sync flag.
SYNC_GATEWAYS: 'ppcp/onboarding/SYNC_GATEWAYS',
REFRESH_GATEWAYS: 'ppcp/onboarding/REFRESH_GATEWAYS',
// Tracking.
CLEAR_FIELD_SOURCE: 'ppcp/onboarding/CLEAR_FIELD_SOURCE',
};

View file

@ -16,14 +16,19 @@ import { REST_PERSIST_PATH } from './constants';
* @typedef {Object} Action An action object that is handled by a reducer or control.
* @property {string} type - The action type.
* @property {Object?} payload - Optional payload for the action.
* @property {string?} source - Optional source context for tracking.
*/
/**
* Special. Resets all values in the onboarding store to initial defaults.
*
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const reset = () => ( { type: ACTION_TYPES.RESET } );
export const reset = ( source ) => ( {
type: ACTION_TYPES.RESET,
source,
} );
/**
* Persistent. Set the full onboarding details, usually during app initialization.
@ -34,30 +39,37 @@ export const reset = () => ( { type: ACTION_TYPES.RESET } );
export const hydrate = ( payload ) => ( {
type: ACTION_TYPES.HYDRATE,
payload,
source: 'system',
} );
/**
* Generic transient-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setTransient = ( prop, value ) => ( {
export const setTransient = ( prop, value, source = '' ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { [ prop ]: value },
source,
fieldName: prop,
} );
/**
* Generic persistent-data updater.
*
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setPersistent = ( prop, value ) => ( {
export const setPersistent = ( prop, value, source ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
source,
fieldName: prop,
} );
/**
@ -66,7 +78,8 @@ export const setPersistent = ( prop, value ) => ( {
* @param {boolean} isReady
* @return {Action} The action.
*/
export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady );
export const setIsReady = ( isReady ) =>
setTransient( 'isReady', isReady, 'system' );
/**
* Thunk action creator. Triggers the persistence of onboarding data to the server.
@ -104,39 +117,139 @@ export function refresh() {
/**
* Persistent. Updates the gateway synced status.
*
* @param {boolean} synced The sync status to set
* @param {boolean} synced The sync status to set.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const updateGatewaysSynced = ( synced = true ) =>
setPersistent( 'gatewaysSynced', synced );
export const updateGatewaysSynced = ( synced = true, source ) =>
setPersistent( 'gatewaysSynced', synced, source );
/**
* Persistent. Updates the gateway refreshed status.
*
* @param {boolean} refreshed The refreshed status to set
* @param {boolean} refreshed The refreshed status to set.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const updateGatewaysRefreshed = ( refreshed = true ) =>
setPersistent( 'gatewaysRefreshed', refreshed );
export const updateGatewaysRefreshed = ( refreshed = true, source ) =>
setPersistent( 'gatewaysRefreshed', refreshed, source );
/**
* Action creator to sync payment gateways.
* This will both update the state and persist it.
*
* @param {string} source Optional source context for tracking.
* @return {Function} The thunk function.
*/
export function syncGateways() {
export function syncGateways( source ) {
return async ( { dispatch } ) => {
dispatch( setPersistent( 'gatewaysSynced', true ) );
dispatch( setPersistent( 'gatewaysSynced', true, source ) );
await dispatch.persist();
return { success: true };
};
}
export function refreshGateways() {
/**
* Action creator to refresh payment gateways.
*
* @param {string} source Optional source context for tracking.
* @return {Function} The thunk function.
*/
export function refreshGateways( source ) {
return async ( { dispatch } ) => {
dispatch( setPersistent( 'gatewaysRefreshed', true ) );
dispatch( setPersistent( 'gatewaysRefreshed', true, source ) );
await dispatch.persist();
return { success: true };
};
}
/**
* Action creator to clear field source tracking.
*
* @param {string} fieldName - Name of the field to clear source for.
* @return {Action} The action.
*/
export const clearFieldSource = ( fieldName ) => ( {
type: ACTION_TYPES.CLEAR_FIELD_SOURCE,
payload: { fieldName },
} );
/**
* Transient. Updates the connection button clicked status.
*
* @param {boolean} clicked Whether the button was clicked.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setConnectionButtonClicked = ( clicked = true, source = 'user' ) =>
setTransient( 'connectionButtonClicked', clicked, source );
/**
* Persistent. Updates the current step in the onboarding flow.
*
* @param {number} step The step number to set.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setStep = ( step, source ) =>
setPersistent( 'step', step, source );
/**
* Persistent. Updates the completed status of the onboarding.
*
* @param {boolean} completed Whether onboarding is completed.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setCompleted = ( completed, source ) =>
setPersistent( 'completed', completed, source );
/**
* Persistent. Updates the casual seller status.
*
* @param {boolean} isCasualSeller Whether the user is a casual seller.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setIsCasualSeller = ( isCasualSeller, source ) =>
setPersistent( 'isCasualSeller', isCasualSeller, source );
/**
* Persistent. Updates the optional payment methods setting.
*
* @param {boolean} enabled Whether optional payment methods are enabled.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setOptionalPaymentMethods = ( enabled, source ) =>
setPersistent( 'areOptionalPaymentMethodsEnabled', enabled, source );
/**
* Persistent. Updates the selected products.
*
* @param {Array} products Array of selected product types.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setProducts = ( products, source ) =>
setPersistent( 'products', products, source );
/**
* Transient. Updates the manual client ID.
*
* @param {string} clientId The manual client ID.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setManualClientId = ( clientId, source ) =>
setTransient( 'manualClientId', clientId, source );
/**
* Transient. Updates the manual client secret.
*
* @param {string} clientSecret The manual client secret.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setManualClientSecret = ( clientSecret, source ) =>
setTransient( 'manualClientSecret', clientSecret, source );

View file

@ -12,11 +12,10 @@ import { useSelect, useDispatch } from '@wordpress/data';
import { createHooksForStore } from '../utils';
import { PRODUCT_TYPES } from './configuration';
import { STORE_NAME } from './constants';
import ACTION_TYPES from './action-types';
const useHooks = () => {
const { useTransient, usePersistent } = createHooksForStore( STORE_NAME );
const { persist, dispatch } = useDispatch( STORE_NAME );
const dispatchActions = useDispatch( STORE_NAME );
// Read-only flags and derived state.
const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] );
@ -27,6 +26,8 @@ const useHooks = () => {
useTransient( 'manualClientId' );
const [ manualClientSecret, setManualClientSecret ] =
useTransient( 'manualClientSecret' );
const [ connectionButtonClicked, setConnectionButtonClicked ] =
useTransient( 'connectionButtonClicked' );
// Persistent accessors.
const [ step, setStep ] = usePersistent( 'step' );
@ -37,32 +38,34 @@ const useHooks = () => {
'areOptionalPaymentMethodsEnabled'
);
const [ products, setProducts ] = usePersistent( 'products' );
// Add the setter for gatewaysSynced
const [ gatewaysSynced, setGatewaysSynced ] =
usePersistent( 'gatewaysSynced' );
const [ gatewaysRefreshed, setGatewaysRefreshed ] =
usePersistent( 'gatewaysRefreshed' );
const savePersistent = async ( setter, value ) => {
setter( value );
await persist();
const savePersistent = async ( setter, value, source ) => {
setter( value, source );
await dispatchActions.persist();
};
const saveTransient = ( setter, value, source ) => {
setter( value, source );
};
return {
flags,
isReady,
step,
setStep: ( value ) => {
return savePersistent( setStep, value );
setStep: ( value, source ) => {
return savePersistent( setStep, value, source );
},
completed,
setCompleted: ( state ) => {
return savePersistent( setCompleted, state );
setCompleted: ( state, source ) => {
return savePersistent( setCompleted, state, source );
},
isCasualSeller,
setIsCasualSeller: ( value ) => {
return savePersistent( setIsCasualSeller, value );
setIsCasualSeller: ( value, source ) => {
return savePersistent( setIsCasualSeller, value, source );
},
manualClientId,
setManualClientId: ( value ) => {
@ -73,35 +76,33 @@ const useHooks = () => {
return savePersistent( setManualClientSecret, value );
},
optionalMethods,
setOptionalMethods: ( value ) => {
return savePersistent( setOptionalMethods, value );
setOptionalMethods: ( value, source ) => {
return savePersistent( setOptionalMethods, value, source );
},
products,
setProducts: ( activeProducts ) => {
setProducts: ( activeProducts, source ) => {
const validProducts = activeProducts.filter( ( item ) =>
Object.values( PRODUCT_TYPES ).includes( item )
);
return savePersistent( setProducts, validProducts );
return savePersistent( setProducts, validProducts, source );
},
gatewaysSynced,
setGatewaysSynced: ( value ) => {
return savePersistent( setGatewaysSynced, value );
return savePersistent( setGatewaysSynced, value, undefined );
},
syncGateways: async () => {
await savePersistent( setGatewaysSynced, true );
dispatch( {
type: ACTION_TYPES.SYNC_GATEWAYS,
} );
return await dispatchActions.syncGateways( undefined );
},
gatewaysRefreshed,
setGatewaysRefreshed: ( value ) => {
return savePersistent( setGatewaysRefreshed, value );
return savePersistent( setGatewaysRefreshed, value, undefined );
},
refreshGateways: async () => {
await savePersistent( setGatewaysRefreshed, true );
dispatch( {
type: ACTION_TYPES.REFRESH_GATEWAYS,
} );
return await dispatchActions.refreshGateways( undefined );
},
connectionButtonClicked,
setConnectionButtonClicked: ( value ) => {
return saveTransient( setConnectionButtonClicked, value, 'user' );
},
};
};
@ -188,3 +189,26 @@ export const useGatewayRefresh = () => {
const { gatewaysRefreshed, refreshGateways } = useHooks();
return { gatewaysRefreshed, refreshGateways };
};
export const useConnectionButton = () => {
const { connectionButtonClicked, setConnectionButtonClicked } = useHooks();
return {
connectionButtonClicked,
setConnectionButtonClicked,
};
};
export const OnboardingHooks = {
useManualConnectionForm,
useBusiness,
useProducts,
useOptionalPaymentMethods,
useSteps,
useNavigationState,
useDetermineProducts,
useFlags,
useGatewaySync,
useGatewayRefresh,
useConnectionButton,
};

View file

@ -7,6 +7,9 @@ import * as actions from './actions';
import * as hooks from './hooks';
import * as resolvers from './resolvers';
import { addStoreToFunnel } from '../../services/tracking';
import { ONBOARDING_FUNNEL_ID } from '../../services/tracking/init';
/**
* Initializes and registers the settings store with WordPress data layer.
* Combines custom controls with WordPress data controls.
@ -23,6 +26,8 @@ export const initStore = () => {
register( store );
addStoreToFunnel( STORE_NAME, ONBOARDING_FUNNEL_ID );
return Boolean( wp.data.select( STORE_NAME ) );
};

View file

@ -16,6 +16,8 @@ const defaultTransient = Object.freeze( {
isReady: false,
manualClientId: '',
manualClientSecret: '',
connectionButtonClicked: false,
fieldSources: Object.freeze( {} ),
// Read only values, provided by the server.
flags: Object.freeze( {
@ -39,6 +41,26 @@ const defaultPersistent = Object.freeze( {
gatewaysRefreshed: false,
} );
const updateFieldSources = ( currentSources, fieldName, source ) => {
if ( ! source ) {
return currentSources;
}
return {
...currentSources,
[ fieldName ]: {
source,
timestamp: Date.now(),
},
};
};
const clearFieldSource = ( currentSources, fieldName ) => {
const newSources = { ...currentSources };
delete newSources[ fieldName ];
return newSources;
};
// Reducer logic.
const [ changeTransient, changePersistent ] = createReducerSetters(
@ -47,13 +69,48 @@ const [ changeTransient, changePersistent ] = createReducerSetters(
);
const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) =>
changeTransient( state, payload ),
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload, action ) => {
const newState = changeTransient( state, payload );
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) =>
changePersistent( state, payload ),
if ( action && action.source ) {
const fieldName = Object.keys( payload )[ 0 ];
[ ACTION_TYPES.RESET ]: ( state ) => {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
fieldName,
action.source
);
}
return newState;
},
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload, action ) => {
const newState = changePersistent( state, payload );
if ( action && action.source ) {
const fieldName = Object.keys( payload )[ 0 ];
newState.fieldSources = updateFieldSources(
newState.fieldSources,
fieldName,
action.source
);
}
return newState;
},
[ ACTION_TYPES.CLEAR_FIELD_SOURCE ]: ( state, payload ) => {
const newState = { ...state };
newState.fieldSources = clearFieldSource(
newState.fieldSources,
payload.fieldName
);
return newState;
},
[ ACTION_TYPES.RESET ]: ( state, payload, action ) => {
const cleanState = changeTransient(
changePersistent( state, defaultPersistent ),
defaultTransient
@ -63,11 +120,37 @@ const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
cleanState.flags = { ...state.flags };
cleanState.isReady = true;
if ( action && action.source ) {
cleanState.fieldSources = updateFieldSources(
cleanState.fieldSources,
'reset',
action.source
);
}
return cleanState;
},
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
const newState = changePersistent( state, payload.data );
[ ACTION_TYPES.HYDRATE ]: ( state, payload, action ) => {
let newState = { ...state };
if ( action && action.source && payload.data ) {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
'hydrate',
action.source
);
Object.keys( payload.data ).forEach( ( fieldName ) => {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
fieldName,
action.source
);
} );
}
newState = changePersistent( newState, payload.data );
// Flags are not updated by `changePersistent()`.
if ( payload.flags ) {
@ -80,12 +163,34 @@ const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
return newState;
},
[ ACTION_TYPES.SYNC_GATEWAYS ]: ( state ) => {
return changePersistent( state, { gatewaysSynced: true } );
[ ACTION_TYPES.SYNC_GATEWAYS ]: ( state, payload, action ) => {
const newState = changePersistent( state, { gatewaysSynced: true } );
if ( action && action.source ) {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
'gatewaysSynced',
action.source
);
}
return newState;
},
[ ACTION_TYPES.REFRESH_GATEWAYS ]: ( state ) => {
return changePersistent( state, { gatewaysRefreshed: true } );
[ ACTION_TYPES.REFRESH_GATEWAYS ]: ( state, payload, action ) => {
const newState = changePersistent( state, { gatewaysRefreshed: true } );
if ( action ) {
if ( action.source ) {
newState.fieldSources = updateFieldSources(
newState.fieldSources,
'gatewaysRefreshed',
action.source
);
}
}
return newState;
},
} );

View file

@ -18,8 +18,8 @@ export function persistentData() {
try {
const result = await apiFetch( { path: REST_HYDRATE_PATH } );
await dispatch.hydrate( result );
await dispatch.setIsReady( true );
await dispatch.hydrate( result, 'system' );
await dispatch.setIsReady( true, 'system' );
} catch ( e ) {
await registry
.dispatch( 'core/notices' )

View file

@ -18,7 +18,7 @@ export const persistentData = ( state ) => {
};
export const transientData = ( state ) => {
const { data, flags, ...transientState } = getState( state );
const { data, flags, fieldSources, ...transientState } = getState( state );
return transientState || EMPTY_OBJ;
};
@ -26,6 +26,43 @@ export const flags = ( state ) => {
return getState( state ).flags || EMPTY_OBJ;
};
/**
* Get the source information for a specific field.
* @param {Object} state - Store state.
* @param {string} fieldName
*/
export const getFieldSource = ( state, fieldName ) => {
const fieldSources = state?.fieldSources || {};
const fieldSource = fieldSources[ fieldName ];
if ( ! fieldSource ) {
return null;
}
return fieldSource;
};
/**
* Get all field sources for debugging.
* @param {Object} state - Store state.
* @return {Object} All field source information.
*/
export const getAllFieldSources = ( state ) => {
const currentState = getState( state );
return currentState.fieldSources || {};
};
/**
* Get the connection button clicked status.
*
* @param {Object} state - Store state.
* @return {boolean} Whether the connection button has been clicked.
*/
export const getConnectionButtonClicked = ( state ) => {
const transient = transientData( state );
return transient.connectionButtonClicked || false;
};
/**
* Returns details about products and capabilities to use for the production login link in
* the last onboarding step.

View file

@ -74,7 +74,11 @@ export const createReducer = (
return function reducer( state = initialState, action ) {
if ( Object.hasOwnProperty.call( handlers, action.type ) ) {
return handlers[ action.type ]( state, action.payload ?? {} );
return handlers[ action.type ](
state,
action.payload ?? {},
action
);
}
return state;
@ -121,13 +125,13 @@ export const createHooksForStore = ( storeName ) => {
const actions = useDispatch( storeName );
const setValue = useCallback(
( newValue ) => {
( newValue, source ) => {
if ( ! actions?.[ dispatcher ] ) {
throw new Error(
`Please create the action "${ dispatcher }" for store "${ storeName }"`
);
}
actions[ dispatcher ]( key, newValue );
actions[ dispatcher ]( key, newValue, source );
},
[ actions, key ]
);

View file

@ -0,0 +1,51 @@
/**
* Console Logger Adapter: Simple tracking event logger for browser console.
*
* Logs tracking events to the browser console with configurable formatting.
* Useful for development, debugging, and testing tracking implementations.
*
* @file
*/
export class ConsoleLoggerAdapter {
/**
* Creates a new console logger adapter.
* @param {Object} options - Configuration options.
* @param {boolean} [options.enabled=true] - Whether logging is enabled.
* @param {string} [options.prefix='[Track]'] - Prefix for log messages.
*/
constructor( options = {} ) {
this.enabled = options.enabled !== false;
this.prefix = options.prefix || '[Track]';
}
/**
* Track an event by logging to console.
* @param {string} eventName - Name of the event to track.
* @param {Object} properties - Event properties to log.
* @return {boolean} - Success status
*/
track( eventName, properties = {} ) {
if ( ! this.enabled ) {
return false;
}
const hasProperties = Object.keys( properties ).length > 0;
if ( hasProperties ) {
console.log( `${ this.prefix } ${ eventName }`, properties );
} else {
console.log( `${ this.prefix } ${ eventName }` );
}
return true;
}
/**
* Enable/disable logging.
* @param {boolean} enabled - Whether logging is enabled.
*/
setEnabled( enabled ) {
this.enabled = enabled;
}
}

View file

@ -0,0 +1,31 @@
/**
* Adapters Index: Centralized exports for all tracking adapters.
*
* Re-exports all available tracking adapters and provides adapter metadata.
* Serves as the main entry point for importing adapters into the tracking system.
*
* @file
*/
export { WooCommerceTracksAdapter } from './woocommerce-tracks';
export { ConsoleLoggerAdapter } from './console-logger';
/**
* Get info about available adapters.
*/
export const getAvailableAdapters = () => {
return [
{
type: 'woocommerce',
name: 'WooCommerce Tracks',
description: 'Send events to WooCommerce Tracks API',
requiredOptions: [ 'eventPrefix' ],
},
{
type: 'console',
name: 'Console Logger',
description: 'Log events to browser console',
requiredOptions: [],
},
];
};

View file

@ -0,0 +1,282 @@
/**
* WooCommerce Tracks Adapter: Send tracking events to WooCommerce Tracks API.
*
* Handles event transmission to WooCommerce Tracks with proper data sanitization.
* Includes smart prefix handling, event queuing, and WooCommerce naming conventions.
*
* @file
*/
export class WooCommerceTracksAdapter {
/**
* Create a WooCommerce Tracks adapter.
* @param {string} eventPrefix - Prefix for all track events.
* @param {Object} options - Configuration options.
*/
constructor( eventPrefix = 'ppcp_onboarding', options = {} ) {
this.eventPrefix = eventPrefix;
this.debug = options.debugMode || false;
this.isAvailable = this.checkAvailability();
this.pendingEvents = [];
this.setupAvailabilityCheck();
}
/**
* Get the correct tracking function (real system with fallback)
* @return {Function|null} The tracking function to use.
*/
getTrackingFunction() {
// Use wc.tracks.recordEvent (real system) with wcTracks.recordEvent as fallback.
return (
window.wc?.tracks?.recordEvent ??
window.wcTracks?.recordEvent ??
null
);
}
/**
* Check if WooCommerce Tracks is available.
* @return {boolean} Whether WooCommerce Tracks is available.
*/
checkAvailability() {
const trackingFunction = this.getTrackingFunction();
const isAvailable = !! (
typeof window !== 'undefined' &&
trackingFunction &&
typeof trackingFunction === 'function'
);
// Debug which tracking system we're using.
if ( isAvailable && this.debug ) {
if ( window.wc?.tracks?.recordEvent ) {
console.log(
'[WC Tracks] Using wc.tracks.recordEvent (real system)'
);
} else if ( window.wcTracks?.recordEvent ) {
console.log(
'[WC Tracks] Using wcTracks.recordEvent (fallback)'
);
}
}
return isAvailable;
}
/**
* Set up periodic checks for tracks availability.
* @return {void}
*/
setupAvailabilityCheck() {
if ( ! this.isAvailable ) {
const checkInterval = setInterval( () => {
if ( this.checkAvailability() ) {
this.isAvailable = true;
this.processPendingEvents();
clearInterval( checkInterval );
}
}, 1000 );
// Stop checking after 5 seconds.
setTimeout( () => clearInterval( checkInterval ), 5000 );
}
}
/**
* Log debug messages if debug mode is enabled.
* @param {...any} args - Arguments to pass to console.log.
* @return {void}
*/
debugLog( ...args ) {
if ( this.debug ) {
console.log( ...args );
}
}
/**
* Build the full event name with smart prefix handling.
* @param {string} eventName - The base event name.
* @return {string} The full event name.
*/
buildEventName( eventName ) {
// Check if the event name already starts with the expected prefix.
if ( eventName.startsWith( this.eventPrefix + '_' ) ) {
// Event already has the correct prefix, use as-is.
this.debugLog( '[WC Tracks] Event already prefixed:', eventName );
return eventName;
}
// Event doesn't have prefix, add it.
const fullEventName = `${ this.eventPrefix }_${ eventName }`;
this.debugLog(
'[WC Tracks] Adding prefix:',
eventName,
'→',
fullEventName
);
return fullEventName;
}
/**
* Track an event
* @param {string} eventName - The name of the event to track.
* @param {Object} properties - Properties to send with the event.
* @return {boolean} Success status of the tracking event.
*/
track( eventName, properties = {} ) {
if ( ! this.isAvailable ) {
// Queue events if tracks isn't available yet.
this.pendingEvents.push( {
eventName,
properties,
timestamp: Date.now(),
} );
this.debugLog( '[WC Tracks] Not available, queuing:', eventName );
return false;
}
const fullEventName = this.buildEventName( eventName );
// Validate event name follows WooCommerce pattern.
if ( ! this.isValidEventName( fullEventName ) ) {
console.error( '[WC Tracks] Invalid event name:', fullEventName );
return false;
}
try {
// Use the real tracking function.
const trackingFunction = this.getTrackingFunction();
if ( ! trackingFunction ) {
console.error( '[WC Tracks] No tracking function available' );
return false;
}
// Sanitize properties for WooCommerce.
const sanitizedProps = this.sanitizeProperties( properties );
// Call the tracking function (either wc.tracks.recordEvent or wcTracks.recordEvent).
trackingFunction( fullEventName, sanitizedProps );
this.debugLog(
'[WC Tracks] Event sent:',
fullEventName,
sanitizedProps
);
return true;
} catch ( error ) {
console.error( '[WC Tracks] Error sending event:', error );
return false;
}
}
/**
* Process any events that were queued while tracks was unavailable.
* @return {void}
*/
processPendingEvents() {
if ( this.pendingEvents.length === 0 ) {
return;
}
this.debugLog(
`[WC Tracks] Processing ${ this.pendingEvents.length } queued events`
);
this.pendingEvents.forEach( ( { eventName, properties } ) => {
this.track( eventName, properties );
} );
this.pendingEvents = [];
}
/**
* Validate event name follows WooCommerce pattern: ^[a-z_][a-z0-9_]*$.
* @param {string} eventName - The event name to validate.
* @return {boolean} Whether the event name is valid.
*/
isValidEventName( eventName ) {
const pattern = /^[a-z_][a-z0-9_]*$/;
return pattern.test( eventName );
}
/**
* Sanitize properties according to WooCommerce requirements.
* @param {Object} properties - Properties to send with the event.
* @return {Object} Sanitized properties object.
*/
sanitizeProperties( properties ) {
const sanitized = {};
Object.entries( properties ).forEach( ( [ key, value ] ) => {
// Convert key to lowercase with underscores.
const sanitizedKey = key
.toLowerCase()
.replace( /[^a-z0-9_]/g, '_' );
// Skip properties with leading underscores (reserved by WooCommerce).
if ( sanitizedKey.startsWith( '_' ) && ! key.startsWith( '_' ) ) {
return;
}
// Handle different value types.
if ( value === null || value === undefined ) {
sanitized[ sanitizedKey ] = 'null';
} else if ( typeof value === 'boolean' ) {
sanitized[ sanitizedKey ] = value;
} else if ( typeof value === 'number' ) {
sanitized[ sanitizedKey ] = value;
} else if ( Array.isArray( value ) ) {
// Convert arrays to comma-separated strings.
sanitized[ sanitizedKey ] = value.join( ',' );
} else if ( typeof value === 'object' ) {
// Convert objects to JSON strings (truncated for safety).
const jsonString = JSON.stringify( value );
sanitized[ sanitizedKey ] =
jsonString.length > 200
? jsonString.substring( 0, 200 ) + '...'
: jsonString;
} else {
// Convert to string and truncate if too long.
const stringValue = String( value );
sanitized[ sanitizedKey ] =
stringValue.length > 255
? stringValue.substring( 0, 255 ) + '...'
: stringValue;
}
} );
return sanitized;
}
/**
* Get adapter info for debugging.
* @return {Object} Adapter information.
*/
getInfo() {
const trackingFunction = this.getTrackingFunction();
const usingRealSystem = !! window.wc?.tracks?.recordEvent;
return {
name: 'WooCommerce Tracks',
available: this.isAvailable,
eventPrefix: this.eventPrefix,
pendingEvents: this.pendingEvents.length,
debug: this.debug,
usingRealSystem,
trackingFunction: trackingFunction ? 'available' : 'not available',
};
}
/**
* Enable or disable debug mode.
* @param {boolean|Object} options - Boolean or options object with debugMode property.
* @return {void}
*/
setDebugMode( options ) {
if ( typeof options === 'boolean' ) {
this.debug = options;
} else if ( options && typeof options === 'object' ) {
this.debug = !! options.debugMode;
}
}
}

View file

@ -0,0 +1,41 @@
/**
* Funnel Registry: Centralized exports and registration for all tracking funnels.
*
* Provides a single entry point for registering and managing tracking funnels.
* Includes funnel mapping, bulk registration, and individual funnel access.
*
* @file
*/
import { registerFunnel } from '../registry';
import * as onboardingFunnel from './onboarding';
// Map of all available funnels.
export const FUNNELS = {
[ onboardingFunnel.FUNNEL_ID ]: onboardingFunnel,
};
/**
* Register all available funnels.
*/
export function registerAllFunnels() {
Object.values( FUNNELS ).forEach( ( funnel ) => {
registerFunnel( funnel.FUNNEL_ID, funnel.config );
} );
}
/**
* Register a specific funnel by ID.
*
* @param {string} funnelId - The funnel identifier.
* @return {Object|null} Funnel registration or null if not found.
*/
export function registerFunnelById( funnelId ) {
const funnel = FUNNELS[ funnelId ];
if ( ! funnel ) {
console.error( `[Tracking] Funnel ${ funnelId } not found` );
return null;
}
return registerFunnel( funnel.FUNNEL_ID, funnel.config );
}

View file

@ -0,0 +1,289 @@
/**
* Onboarding Funnel: Complete tracking configuration for onboarding flow.
*
* Defines events, translations, and field configurations for PayPal onboarding tracking.
* Includes step progression tracking, user selections, and completion events.
*
* @file
*/
import {
FunnelConfigBuilder,
createFieldTrackingConfig,
createSystemFieldTrackingConfig,
createTransientFieldTrackingConfig,
createBooleanFieldTrackingConfig,
createArrayFieldTrackingConfig,
} from '../utils/field-config-helpers';
export const FUNNEL_ID = 'ppcp_onboarding';
// Event names specific to this funnel.
export const EVENTS = {
welcome_view: 'ppcp_onboarding_welcome_view',
account_type_view: 'ppcp_onboarding_account_type_view',
products_view: 'ppcp_onboarding_products_view',
payment_options_view: 'ppcp_onboarding_payment_options_view',
complete_view: 'ppcp_onboarding_complete_view',
account_type_select: 'ppcp_onboarding_account_type_business_type_select',
products_select: 'ppcp_onboarding_products_products_select',
payment_options_select:
'ppcp_onboarding_payment_options_payment_method_select',
sandbox_mode_select: 'ppcp_onboarding_sandbox_mode_select',
manual_connection_select: 'ppcp_onboarding_manual_connection_select',
complete_connect_click: 'ppcp_onboarding_complete_connect_click',
};
// Step metadata.
export const STEP_INFO = {
0: { name: 'welcome', viewEvent: EVENTS.welcome_view },
1: { name: 'account_type', viewEvent: EVENTS.account_type_view },
2: { name: 'products', viewEvent: EVENTS.products_view },
3: { name: 'payment_options', viewEvent: EVENTS.payment_options_view },
4: { name: 'complete', viewEvent: EVENTS.complete_view },
};
// Translation functions specific to this funnel.
export const TRANSLATIONS = {
step: ( oldStep, newStep, metadata, trackingService ) => {
const stepInfo = STEP_INFO[ newStep ];
if ( ! stepInfo ) {
return;
}
const eventData = {
step_number: newStep,
step_name: stepInfo.name,
...trackingService.getCommonProperties( metadata ),
};
trackingService.sendToAdapters( stepInfo.viewEvent, eventData );
},
isCasualSeller: ( oldValue, newValue, metadata, trackingService ) => {
if ( newValue === null ) {
return;
}
const accountType = newValue === true ? 'personal' : 'business';
const eventData = {
selected_value: accountType,
step_number: metadata.currentStep,
step_name: metadata.stepName,
...trackingService.getCommonProperties( metadata ),
};
trackingService.sendToAdapters( EVENTS.account_type_select, eventData );
},
products: ( oldValue, newValue, metadata, trackingService ) => {
if ( ! Array.isArray( newValue ) ) {
return;
}
const eventData = {
selected_products: newValue.join( ',' ),
products_count: newValue.length,
previous_products: Array.isArray( oldValue )
? oldValue.join( ',' )
: 'none',
step_number: metadata.currentStep,
step_name: metadata.stepName,
...trackingService.getCommonProperties( metadata ),
};
trackingService.sendToAdapters( EVENTS.products_select, eventData );
},
areOptionalPaymentMethodsEnabled: (
oldValue,
newValue,
metadata,
trackingService
) => {
if ( newValue === null ) {
return;
}
const paymentOption = newValue ? 'expanded' : 'no_cards';
const eventData = {
selected_value: paymentOption,
step_number: metadata.currentStep,
step_name: metadata.stepName,
...trackingService.getCommonProperties( metadata ),
};
trackingService.sendToAdapters(
EVENTS.payment_options_select,
eventData
);
},
completed: ( oldValue, newValue, metadata, trackingService ) => {
if ( newValue === true ) {
const eventData = {
step_number: metadata.currentStep,
step_name: metadata.stepName,
total_duration_ms:
Date.now() - trackingService.sessionStartTime,
final_account_type: metadata?.isCasualSeller
? 'personal'
: 'business',
final_products: Array.isArray( metadata?.products )
? metadata.products.join( ',' )
: '',
final_payment_options:
metadata?.areOptionalPaymentMethodsEnabled
? 'expanded'
: 'no_cards',
final_sandbox_mode: metadata?.useSandbox
? 'enabled'
: 'disabled',
...trackingService.getCommonProperties( metadata ),
};
trackingService.sendToAdapters(
EVENTS.complete_connect_click,
eventData
);
}
},
connectionButtonClicked: (
oldValue,
newValue,
metadata,
trackingService
) => {
if ( newValue === true && oldValue === false ) {
const eventData = {
step_number: metadata.currentStep,
step_name: metadata.stepName,
...trackingService.getCommonProperties( metadata ),
};
trackingService.sendToAdapters(
EVENTS.complete_connect_click,
eventData
);
}
},
useSandbox: ( oldValue, newValue, metadata, trackingService ) => {
if ( newValue === null ) {
return;
}
const sandboxMode = newValue === true ? 'enabled' : 'disabled';
const eventData = {
selected_value: sandboxMode,
...trackingService.getCommonProperties( metadata ),
};
trackingService.sendToAdapters( EVENTS.sandbox_mode_select, eventData );
},
useManualConnection: ( oldValue, newValue, metadata, trackingService ) => {
if ( newValue === null ) {
return;
}
const manualMode = newValue === true ? 'enabled' : 'disabled';
const eventData = {
selected_value: manualMode,
...trackingService.getCommonProperties( metadata ),
};
trackingService.sendToAdapters(
EVENTS.manual_connection_select,
eventData
);
},
};
// Field tracking configurations using generic helpers.
const createStepTrackingConfig = () => {
return createSystemFieldTrackingConfig( 'step', 'persistent', {
transform: ( value ) => ( {
step_number: value,
step_name: STEP_INFO[ value ]?.name || `step_${ value }`,
} ),
} );
};
const createAccountTypeTrackingConfig = () => {
return createFieldTrackingConfig( 'isCasualSeller', 'persistent', {
transform: ( value ) => ( {
selected_value: value === true ? 'personal' : 'business',
} ),
} );
};
const createProductsTrackingConfig = () => {
return createArrayFieldTrackingConfig( 'products', 'persistent' );
};
const createPaymentOptionsTrackingConfig = () => {
return createFieldTrackingConfig(
'areOptionalPaymentMethodsEnabled',
'persistent',
{
transform: ( value ) => ( {
selected_value: value === true ? 'expanded' : 'no_cards',
} ),
}
);
};
const createCompletedTrackingConfig = () => {
return createFieldTrackingConfig( 'completed', 'persistent', {
transform: ( value ) => ( {
completed: value === true,
} ),
} );
};
const createConnectionButtonTrackingConfig = () => {
return createTransientFieldTrackingConfig( 'connectionButtonClicked' );
};
const createSandboxTrackingConfig = () => {
return createBooleanFieldTrackingConfig( 'useSandbox', 'persistent' );
};
const createManualConnectionTrackingConfig = () => {
return createBooleanFieldTrackingConfig(
'useManualConnection',
'persistent'
);
};
// Main funnel configuration.
export const config = FunnelConfigBuilder.createBasicFunnel( FUNNEL_ID, {
debug: false,
adapters: [ 'woocommerce-tracks' ],
eventPrefix: 'ppcp_onboarding',
// Only track for onboarding flow (isConnected: false).
trackingCondition: {
store: 'wc/paypal/common',
selector: 'merchant',
field: 'isConnected',
expectedValue: false,
},
} )
.addEvents( EVENTS )
.addTranslations( TRANSLATIONS )
.addStepInfo( STEP_INFO )
.addStore( 'wc/paypal/onboarding', [
createStepTrackingConfig(),
createAccountTypeTrackingConfig(),
createProductsTrackingConfig(),
createPaymentOptionsTrackingConfig(),
createCompletedTrackingConfig(),
createConnectionButtonTrackingConfig(),
] )
.addStore( 'wc/paypal/common', [
createSandboxTrackingConfig(),
createManualConnectionTrackingConfig(),
] )
.build();

View file

@ -0,0 +1,30 @@
/**
* Tracking: Main entry point for the tracking system.
*
* Exports all tracking services, registry functions, adapters, and utilities.
* Provides a centralized interface for funnel tracking and analytics integration.
*
* @file
*/
export { FunnelTrackingService } from './services/funnel-tracking';
export {
registerFunnel,
addStoreToFunnel,
initializeTracking,
getRegisteredFunnels,
getTrackingInstances,
getTrackingInstance,
validateFunnelConfig,
getMultiFunnelStores,
getFunnelsForStore,
getTrackingStatus,
} from './registry';
export { subscriptionManager } from './subscription-manager';
export { FUNNELS, registerAllFunnels, registerFunnelById } from './funnels';
export { WooCommerceTracksAdapter, ConsoleLoggerAdapter } from './adapters';
export * from './utils/field-config-helpers';
export * from './utils';
export { initializeTrackingFunnels, ONBOARDING_FUNNEL_ID } from './init';

View file

@ -0,0 +1,34 @@
/**
* Initialization: Set up and register tracking funnels.
*
* Handles the registration of tracking funnels and provides initialization utilities.
* Must be called before store initialization to ensure proper tracking setup.
*
* @file
*/
import { registerFunnel } from './registry';
import {
FUNNEL_ID as ONBOARDING_FUNNEL_ID,
config as onboardingConfig,
} from './funnels/onboarding';
let initialized = false;
export function initializeTrackingFunnels() {
if ( initialized ) {
return;
}
registerFunnel( ONBOARDING_FUNNEL_ID, onboardingConfig );
initialized = true;
}
export function isTrackingInitialized() {
return initialized;
}
initializeTrackingFunnels();
export { ONBOARDING_FUNNEL_ID };

View file

@ -0,0 +1,446 @@
/**
* Registry: Manages tracking funnel registration and store coordination.
*
* Handles registration of tracking funnels with support for multiple funnels per store.
* Coordinates with SubscriptionManager for unified tracking across all registered funnels.
*
* @file
*/
import { WooCommerceTracksAdapter, ConsoleLoggerAdapter } from './adapters';
import { FunnelTrackingService } from './services/funnel-tracking';
import { subscriptionManager } from './subscription-manager';
// Registry to track funnels and their configurations.
const trackingRegistry = {
funnels: {},
storeToFunnel: {}, // Store name -> array of funnel IDs
instances: {},
};
/**
* Register a tracking funnel with its configuration.
* @param {string} funnelId - Unique identifier for the funnel.
* @param {Object} config - Funnel configuration including tracking conditions.
*/
export function registerFunnel( funnelId, config ) {
const fullConfig = {
debug: false,
adapters: [ 'console' ],
eventPrefix: 'ppcp_general',
fieldConfigs: {},
events: {},
translations: {},
stepInfo: {},
trackingCondition: null,
...config,
funnelId,
};
// Validate tracking condition if provided.
if ( fullConfig.trackingCondition ) {
const validation = validateTrackingCondition(
fullConfig.trackingCondition
);
if ( ! validation.valid ) {
console.error(
`[REGISTRY] Invalid tracking condition for funnel ${ funnelId }:`,
validation.errors
);
}
}
// Store the funnel registration.
trackingRegistry.funnels[ funnelId ] = {
funnelId,
config: fullConfig,
stores: [],
isInitialized: false,
};
return {
funnelId,
config: fullConfig,
};
}
/**
* Add a store to a funnel for tracking.
* @param {string} storeName - Name of the store to add.
* @param {string} funnelId - ID of the funnel to add the store to.
*/
export function addStoreToFunnel( storeName, funnelId ) {
// Check if funnel exists.
if ( ! trackingRegistry.funnels[ funnelId ] ) {
console.error( `[REGISTRY] Funnel ${ funnelId } does not exist` );
return false;
}
// Add store to funnel if not already added.
if ( ! trackingRegistry.funnels[ funnelId ].stores.includes( storeName ) ) {
trackingRegistry.funnels[ funnelId ].stores.push( storeName );
}
// Map store to funnel.
if ( ! trackingRegistry.storeToFunnel[ storeName ] ) {
trackingRegistry.storeToFunnel[ storeName ] = [];
}
if ( ! trackingRegistry.storeToFunnel[ storeName ].includes( funnelId ) ) {
trackingRegistry.storeToFunnel[ storeName ].push( funnelId );
}
return true;
}
/**
* Initialize all registered tracking funnels.
*/
export function initializeTracking() {
const initialized = {};
// Initialize each registered funnel.
Object.values( trackingRegistry.funnels ).forEach( ( funnel ) => {
if ( ! funnel.isInitialized ) {
const instance = initializeTrackingFunnel( funnel.funnelId );
if ( instance ) {
initialized[ funnel.funnelId ] = instance;
trackingRegistry.funnels[
funnel.funnelId
].isInitialized = true;
}
}
} );
return initialized;
}
/**
* Initialize a single tracking funnel with subscription manager coordination.
* @param {string} funnelId - The funnel ID to initialize.
*/
function initializeTrackingFunnel( funnelId ) {
const funnel = trackingRegistry.funnels[ funnelId ];
if ( ! funnel ) {
console.error( `[REGISTRY] Funnel ${ funnelId } not found` );
return null;
}
const { config, stores } = funnel;
// Skip if no stores are registered for this funnel.
if ( stores.length === 0 ) {
console.warn(
`[REGISTRY] No stores registered for funnel ${ funnelId }`
);
return null;
}
const trackingService = new FunnelTrackingService( config, {
debugMode: config.debug,
} );
// Add requested adapters.
if ( config.adapters.includes( 'woocommerce-tracks' ) ) {
trackingService.addAdapter(
new WooCommerceTracksAdapter( config.eventPrefix, {
debugMode: config.debug,
} )
);
}
if ( config.adapters.includes( 'console' ) || config.debug ) {
trackingService.addAdapter(
new ConsoleLoggerAdapter( {
enabled: true,
logLevel: config.debug ? 'debug' : 'info',
prefix: `[${ funnelId }]`,
colorize: true,
showTimestamp: true,
} )
);
}
const registrations = [];
stores.forEach( ( storeName ) => {
// Check if WordPress data store exists.
if ( ! wp.data || ! wp.data.select( storeName ) ) {
console.warn(
`[REGISTRY] Store ${ storeName } not available for funnel ${ funnelId }`
);
return;
}
// Get field configs for this store in this funnel.
const fieldConfigs = config.fieldConfigs[ storeName ] || [];
// Extract field rules from field configs.
const fieldRules = {};
fieldConfigs.forEach( ( fieldConfig ) => {
if ( fieldConfig.rules ) {
fieldRules[ fieldConfig.fieldName ] = fieldConfig.rules;
}
} );
// Register this funnel for the store with the subscription manager.
const registration = subscriptionManager.registerFunnelForStore(
storeName,
funnelId,
trackingService,
fieldRules,
fieldConfigs,
config.debug,
config.trackingCondition,
config.stepInfo
);
registrations.push( {
storeName,
registration,
} );
} );
// Create the tracking instance.
const instance = {
funnelId,
trackingService,
stores,
config,
trackingCondition: config.trackingCondition,
registrations, // Store the subscription manager registrations.
unsubscribe: () => {
// Unregister from subscription manager.
registrations.forEach( ( { storeName } ) => {
subscriptionManager.unregisterFunnelForStore(
storeName,
funnelId
);
} );
delete trackingRegistry.instances[ funnelId ];
},
getConditionStatus: () => {
const storeStatuses = {};
registrations.forEach( ( { storeName, registration } ) => {
storeStatuses[ storeName ] = {
isActive: registration.isActive,
conditionMet: registration.lastConditionResult,
conditionChecks: registration.conditionCheckCount,
initAttempts: registration.initializationAttempts,
};
} );
return storeStatuses;
},
testCondition: () => {
const results = {};
registrations.forEach( ( { storeName, registration } ) => {
// Force condition check by accessing the subscription manager.
const conditionMet =
subscriptionManager.evaluateTrackingCondition(
wp.data.select,
registration.trackingCondition,
registration
);
results[ storeName ] = {
conditionMet,
registration: {
funnelId: registration.funnelId,
isActive: registration.isActive,
lastResult: registration.lastConditionResult,
},
};
} );
return results;
},
// Get detailed status from subscription manager.
getDetailedStatus: () => {
return {
funnelId,
stores,
trackingCondition: config.trackingCondition,
storeStatuses: instance.getConditionStatus(),
subscriptionManagerStatus: subscriptionManager.getStatus(),
adapterCount: trackingService.adapters.length,
eventCount: trackingService.eventCount,
};
},
};
// Store the instance.
trackingRegistry.instances[ funnelId ] = instance;
return instance;
}
/**
* Get all registered tracking funnels.
*/
export function getRegisteredFunnels() {
return { ...trackingRegistry.funnels };
}
/**
* Get all initialized tracking instances.
*/
export function getTrackingInstances() {
return { ...trackingRegistry.instances };
}
/**
* Helper to get a specific tracking instance.
* @param {string} funnelId - The funnel ID.
*/
export function getTrackingInstance( funnelId ) {
return trackingRegistry.instances[ funnelId ] || null;
}
/**
* Get stores that are tracked by multiple funnels.
* @return {Object} Store name -> array of funnel IDs.
*/
export function getMultiFunnelStores() {
const multiFunnelStores = {};
Object.entries( trackingRegistry.storeToFunnel ).forEach(
( [ storeName, funnelIds ] ) => {
if ( funnelIds.length > 1 ) {
multiFunnelStores[ storeName ] = funnelIds;
}
}
);
return multiFunnelStores;
}
/**
* Get all funnels tracking a specific store.
* @param {string} storeName - The store name.
* @return {Array} Array of funnel IDs.
*/
export function getFunnelsForStore( storeName ) {
return trackingRegistry.storeToFunnel[ storeName ] || [];
}
/**
* Get comprehensive tracking status.
* @return {Object} Complete status information.
*/
export function getTrackingStatus() {
const status = {
totalFunnels: Object.keys( trackingRegistry.funnels ).length,
initializedFunnels: Object.keys( trackingRegistry.instances ).length,
totalStores: Object.keys( trackingRegistry.storeToFunnel ).length,
multiFunnelStores: getMultiFunnelStores(),
subscriptionManagerStatus: subscriptionManager.getStatus(),
funnelDetails: {},
};
// Add details for each funnel.
Object.values( trackingRegistry.instances ).forEach( ( instance ) => {
status.funnelDetails[ instance.funnelId ] =
instance.getDetailedStatus();
} );
return status;
}
/**
* Validate tracking condition configuration.
* @param {Object|null} trackingCondition - The condition to validate.
* @return {Object} Validation result with valid flag, errors, and condition.
*/
function validateTrackingCondition( trackingCondition ) {
if ( ! trackingCondition ) {
return { valid: true, message: 'No condition specified' };
}
const errors = [];
if ( ! trackingCondition.store ) {
errors.push( 'Missing required field: store' );
}
if ( ! trackingCondition.selector ) {
errors.push( 'Missing required field: selector' );
}
return {
valid: errors.length === 0,
errors,
condition: trackingCondition,
};
}
/**
* Helper to check if a funnel is properly configured.
* @param {string} funnelId - The funnel ID to validate.
* @return {Object} Validation result with valid flag, errors, warnings, and stats.
*/
export function validateFunnelConfig( funnelId ) {
const funnel = trackingRegistry.funnels[ funnelId ];
if ( ! funnel ) {
return { valid: false, errors: [ `Funnel ${ funnelId } not found` ] };
}
const { config } = funnel;
const errors = [];
const warnings = [];
// Check if events are defined.
if ( ! config.events || Object.keys( config.events ).length === 0 ) {
warnings.push( 'No events defined for funnel' );
}
// Check if field configs exist.
if (
! config.fieldConfigs ||
Object.keys( config.fieldConfigs ).length === 0
) {
errors.push( 'No field configurations defined' );
}
// Check if translations exist for tracked fields.
const allFields = [];
Object.values( config.fieldConfigs ).forEach( ( storeFields ) => {
allFields.push( ...storeFields.map( ( f ) => f.fieldName ) );
} );
const missingTranslations = allFields.filter(
( field ) => ! config.translations[ field ]
);
if ( missingTranslations.length > 0 ) {
warnings.push(
`Missing translations for fields: ${ missingTranslations.join(
', '
) }`
);
}
// Validate tracking condition if present.
if ( config.trackingCondition ) {
const conditionValidation = validateTrackingCondition(
config.trackingCondition
);
if ( ! conditionValidation.valid ) {
errors.push(
`Invalid tracking condition: ${ conditionValidation.errors.join(
', '
) }`
);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
stats: {
stores: Object.keys( config.fieldConfigs ).length,
fields: allFields.length,
events: Object.keys( config.events ).length,
translations: Object.keys( config.translations ).length,
trackingCondition: config.trackingCondition ? 'configured' : 'none',
},
};
}

View file

@ -0,0 +1,314 @@
/**
* Funnel Tracking Service: Generic tracking service with funnel-specific translations.
*
* Processes state changes and routes events through funnel-specific translation functions.
* Manages adapters, session tracking, and provides field-level source filtering.
*
* @file
*/
export class FunnelTrackingService {
/**
* @param {Object} funnelConfig - Funnel configuration object.
* @param {Object} [options={}] - Optional configuration.
* @param {boolean} [options.debugMode] - Enable debug mode.
*/
constructor( funnelConfig, options = {} ) {
this.funnelConfig = funnelConfig;
this.adapters = [];
this.sessionStartTime = Date.now();
this.sessionId = this.generateSessionId();
this.eventCount = 0;
this.debugMode = options.debugMode || false;
// Get funnel-specific configurations.
this.events = funnelConfig.events || {};
this.translations = funnelConfig.translations || {};
this.stepInfo = funnelConfig.stepInfo || {};
// Sources that are completely ignored.
this.ignoredSources = new Set( [
'subscription',
'unknown',
undefined,
'', // Empty source.
] );
// Force debug for PayPal funnels.
if (
funnelConfig.funnelId?.includes( 'ppcp' ) ||
funnelConfig.funnelId?.includes( 'paypal' )
) {
this.debugMode = true;
}
// Expose globally for debugging.
if ( typeof window !== 'undefined' ) {
window.funnelTrackingService = this;
}
}
/**
* Generate a unique session ID.
* @return {string} Generated session ID.
*/
generateSessionId() {
return `${
this.funnelConfig.eventPrefix || 'tracking'
}_${ Date.now() }_${ Math.random().toString( 36 ).slice( 2, 11 ) }`;
}
/**
* Register a tracking adapter.
* @param {Object} adapter - Tracking adapter instance.
*/
addAdapter( adapter ) {
this.adapters.push( adapter );
}
/**
* Remove all adapters.
*/
clearAdapters() {
this.adapters = [];
}
/**
* Get all registered adapters.
* @return {Array} Array of adapter information.
*/
getAdapters() {
return this.adapters.map(
( adapter ) => adapter.getInfo?.() || adapter
);
}
/**
* Process state changes using funnel-specific logic.
* @param {Object} changeEvent - State change event object.
* @param {string} changeEvent.field - Field name that changed.
* @param {*} changeEvent.oldValue - Previous value.
* @param {*} changeEvent.newValue - New value.
* @param {Object} changeEvent.metadata - Additional metadata.
* @param {string|Object} changeEvent.action - Action that triggered the change.
*/
processStateChange( changeEvent ) {
const { field, oldValue, newValue, metadata, action } = changeEvent;
// Skip if no actual change.
if ( oldValue === newValue ) {
return;
}
// Handle different types of action input.
let source;
let actionType;
if ( typeof action === 'string' ) {
source = metadata?.source || '';
actionType = action;
} else if ( action && typeof action === 'object' && action.type ) {
source = action.source || 'unknown';
actionType = action.type;
} else {
return;
}
const fieldName =
field ||
( action?.payload
? Object.keys( action.payload )[ 0 ]
: 'unknown' );
// Filter based on field-specific source rules.
const shouldTrack = this.shouldTrackFieldSource( fieldName, source );
if ( ! shouldTrack ) {
return;
}
// Use funnel-specific translation if available.
this.processTrackedChange( fieldName, oldValue, newValue, {
...metadata,
source,
actionType,
} );
}
/**
* Determine if a field/source combination should be tracked.
* @param {string} fieldName - Name of the field.
* @param {string} source - Source of the change.
* @return {boolean} Whether the field/source should be tracked.
*/
shouldTrackFieldSource( fieldName, source ) {
// Ignore completely blocked sources.
if ( this.ignoredSources.has( source ) ) {
return false;
}
// Find field rules in funnel configuration.
const fieldRules = this.findFieldRules( fieldName );
if ( fieldRules ) {
return fieldRules.allowedSources.includes( source );
}
// Unknown fields are not tracked.
return false;
}
/**
* Find field rules from funnel configuration.
* @param {string} fieldName - Name of the field.
* @return {Object|null} Field rules object or null if not found.
*/
findFieldRules( fieldName ) {
for ( const storeName in this.funnelConfig.fieldConfigs ) {
const storeFields = this.funnelConfig.fieldConfigs[ storeName ];
const fieldConfig = storeFields.find(
( f ) => f.fieldName === fieldName
);
if ( fieldConfig && fieldConfig.rules ) {
return fieldConfig.rules;
}
}
return null;
}
/**
* Process tracked changes using funnel-specific translations.
* @param {string} fieldName - Name of the field.
* @param {*} oldValue - Previous value.
* @param {*} newValue - New value.
* @param {Object} metadata - Additional metadata.
*/
processTrackedChange( fieldName, oldValue, newValue, metadata ) {
// Check if funnel has a specific translation for this field.
const translationFn = this.translations[ fieldName ];
if ( translationFn && typeof translationFn === 'function' ) {
// Use funnel-specific translation.
try {
translationFn( oldValue, newValue, metadata, this );
} catch ( error ) {
console.error(
`[Funnel Tracking] Error in translation for ${ fieldName }:`,
error
);
}
} else {
// Fallback to generic tracking.
this.genericFieldTracking(
fieldName,
oldValue,
newValue,
metadata
);
}
}
/**
* Generic field tracking when no specific translation exists.
* @param {string} fieldName - Name of the field.
* @param {*} oldValue - Previous value.
* @param {*} newValue - New value.
* @param {Object} metadata - Additional metadata.
*/
genericFieldTracking( fieldName, oldValue, newValue, metadata ) {
const eventName = `${ fieldName }_change`;
const properties = {
field_name: fieldName,
old_value: oldValue,
new_value: newValue,
source: metadata.source,
...this.getCommonProperties( metadata ),
};
this.sendToAdapters( eventName, properties );
}
/**
* Get common properties for all tracking events.
* @param {Object} [metadata={}] - Additional metadata.
* @return {Object} Common properties object.
*/
getCommonProperties( metadata = {} ) {
return {};
}
/**
* Send event to all registered adapters.
* @param {string} eventName - Name of the event.
* @param {Object} properties - Event properties.
*/
sendToAdapters( eventName, properties ) {
this.eventCount++;
this.adapters.forEach( ( adapter, index ) => {
try {
// Send only the essential properties.
adapter.track( eventName, properties );
} catch ( error ) {
console.error(
`[Funnel Tracking] Adapter ${ index } error:`,
error,
adapter.getInfo?.() || 'unknown adapter'
);
}
} );
}
/**
* Get tracking service statistics.
* @return {Object} Statistics object.
*/
getStats() {
const stats = {
sessionId: this.sessionId,
sessionStartTime: this.sessionStartTime,
sessionDuration: Date.now() - this.sessionStartTime,
eventCount: this.eventCount,
adaptersCount: this.adapters.length,
debugMode: this.debugMode,
funnelId: this.funnelConfig.funnelId,
eventsAvailable: Object.keys( this.events ).length,
translationsAvailable: Object.keys( this.translations ).length,
fieldConfigStores: Object.keys(
this.funnelConfig.fieldConfigs || {}
),
totalFieldConfigs: Object.values(
this.funnelConfig.fieldConfigs || {}
).reduce( ( total, configs ) => total + configs.length, 0 ),
};
return stats;
}
/**
* Debug helper to test field source tracking.
* @param {string} fieldName - Name of the field to test.
* @param {string} source - Source to test.
* @return {Object} Test results object.
*/
testFieldSourceTracking( fieldName, source ) {
return {
field: fieldName,
source,
shouldTrack: this.shouldTrackFieldSource( fieldName, source ),
fieldRules: this.findFieldRules( fieldName ),
ignoredSources: Array.from( this.ignoredSources ),
};
}
}
/**
* Factory function to create tracking service with funnel configuration.
* @param {Object} funnelConfig - Funnel configuration object.
* @param {Object} [options={}] - Optional configuration.
* @return {FunnelTrackingService} New tracking service instance.
*/
export function createFunnelTrackingService( funnelConfig, options = {} ) {
return new FunnelTrackingService( funnelConfig, options );
}

View file

@ -0,0 +1,852 @@
/**
* Subscription Manager: Coordinates multiple funnels tracking the same store.
*
* Manages unified subscriptions to prevent conflicts when multiple tracking funnels
* monitor the same WordPress data store. Handles condition evaluation and state coordination.
*
* @file
*/
import { shouldTrackFieldSource, getFieldValue } from './utils';
/**
* Manages unified subscriptions for stores that are tracked by multiple funnels.
*/
export class SubscriptionManager {
constructor() {
// Store name -> subscription info.
this.storeSubscriptions = {};
// Store name -> array of funnel registrations.
this.storeRegistrations = {};
this.debugMode = false;
}
/**
* Register a funnel's interest in tracking a store.
* @param {string} storeName - Name of the store.
* @param {string} funnelId - ID of the funnel.
* @param {Object} trackingService - Funnel's tracking service.
* @param {Object} fieldRules - Field rules for this funnel.
* @param {Array} fieldConfigs - Field configurations for this funnel.
* @param {boolean} debugMode - Debug mode for this funnel.
* @param {Object|null} trackingCondition - Tracking condition for this funnel.
* @param {Object} stepInfo - Step information for this funnel.
*/
registerFunnelForStore(
storeName,
funnelId,
trackingService,
fieldRules,
fieldConfigs,
debugMode,
trackingCondition = null,
stepInfo = {}
) {
// Initialize store registrations if needed.
if ( ! this.storeRegistrations[ storeName ] ) {
this.storeRegistrations[ storeName ] = [];
}
// Check if already registered.
const existingIndex = this.storeRegistrations[ storeName ].findIndex(
( reg ) => reg.funnelId === funnelId
);
const registration = {
funnelId,
trackingService,
fieldRules,
fieldConfigs,
debugMode,
trackingCondition,
stepInfo,
isActive: false,
previousValues: {},
hasTrackedPageLoad: false,
initializationAttempts: 0,
lastConditionResult: null,
conditionCheckCount: 0,
};
if ( existingIndex >= 0 ) {
// Update existing registration.
this.storeRegistrations[ storeName ][ existingIndex ] =
registration;
} else {
// Add new registration.
this.storeRegistrations[ storeName ].push( registration );
}
// Enable debug if any funnel has it enabled.
if ( debugMode ) {
this.debugMode = true;
}
// Create or update the unified subscription for this store.
this.ensureStoreSubscription( storeName );
if ( this.debugMode ) {
console.log(
`[SubscriptionManager] Registered funnel ${ funnelId } for store ${ storeName }. ` +
`Total funnels for this store: ${ this.storeRegistrations[ storeName ].length }`
);
}
return registration;
}
/**
* Ensure a unified subscription exists for a store.
* @param {string} storeName - Name of the store.
*/
ensureStoreSubscription( storeName ) {
// Skip if subscription already exists.
if ( this.storeSubscriptions[ storeName ] ) {
return;
}
// Create unified subscription.
const unsubscribe = wp.data.subscribe( () => {
this.handleStoreChange( storeName );
} );
this.storeSubscriptions[ storeName ] = {
unsubscribe,
isActive: true,
};
if ( this.debugMode ) {
console.log(
`[SubscriptionManager] Created unified subscription for store ${ storeName }`
);
}
}
/**
* Handle store changes and route to appropriate funnels.
* @param {string} storeName - Name of the store that changed.
*/
handleStoreChange( storeName ) {
try {
const select = wp.data.select;
const store = select( storeName );
if ( ! store ) {
return;
}
// Get all funnel registrations for this store.
const registrations = this.storeRegistrations[ storeName ] || [];
// Process each funnel registration.
registrations.forEach( ( registration ) => {
try {
this.processFunnelForStore(
storeName,
registration,
select,
store
);
} catch ( error ) {
console.error(
`[SubscriptionManager] Error processing funnel ${ registration.funnelId } for store ${ storeName }:`,
error
);
}
} );
// Handle field source clearing AFTER all funnels have processed.
this.handleFieldSourceClearing( storeName, store, registrations );
} catch ( error ) {
console.error(
`[SubscriptionManager] Error handling store change for ${ storeName }:`,
error
);
}
}
/**
* Process a specific funnel's tracking for a store change.
* @param {string} storeName - Name of the store.
* @param {Object} registration - Funnel registration object.
* @param {Function} select - WordPress data select function.
* @param {Object} store - Store object.
*/
processFunnelForStore( storeName, registration, select, store ) {
const {
funnelId,
trackingService,
fieldRules,
fieldConfigs,
trackingCondition,
} = registration;
// Step 1: Evaluate tracking condition for this funnel.
const conditionMet = this.evaluateTrackingCondition(
select,
trackingCondition,
registration
);
const shouldBeActive = this.handleConditionChange(
registration,
conditionMet
);
if ( ! shouldBeActive ) {
return; // Skip this funnel.
}
// Step 2: Check initialization for this funnel.
if ( ! registration.isActive ) {
registration.initializationAttempts++;
if ( this.isStoreReadyForTracking( store, registration ) ) {
registration.isActive = true;
this.initializePreviousValues(
select,
storeName,
fieldConfigs,
registration.previousValues
);
// Track initial page load if appropriate.
if (
! registration.hasTrackedPageLoad &&
this.shouldTrackPageLoad( storeName ) &&
conditionMet // Double-check condition.
) {
this.trackInitialPageLoad(
select,
storeName,
trackingService,
registration
);
registration.hasTrackedPageLoad = true;
}
} else {
return; // Still waiting for initialization.
}
}
// Step 3: Process field changes for this funnel.
this.processFieldChangesForFunnel(
select,
store,
storeName,
registration,
fieldConfigs,
fieldRules,
trackingService
);
}
/**
* Evaluate tracking condition for a specific funnel.
* @param {Function} select - WordPress data select function.
* @param {Object|null} trackingCondition - Tracking condition.
* @param {Object} registration - Funnel registration.
* @return {boolean} Whether condition is met.
*/
evaluateTrackingCondition( select, trackingCondition, registration ) {
if ( ! trackingCondition ) {
return true; // No condition = always track.
}
registration.conditionCheckCount++;
try {
const conditionStore = select( trackingCondition.store );
if ( ! conditionStore ) {
return false;
}
// Check store readiness.
const storeReadiness = conditionStore.transientData?.()?.isReady;
if ( ! storeReadiness ) {
return false;
}
// Execute selector.
const selectorFn = conditionStore[ trackingCondition.selector ];
if ( typeof selectorFn !== 'function' ) {
return false;
}
const selectorResult = selectorFn();
if ( ! selectorResult || typeof selectorResult !== 'object' ) {
return false;
}
// Evaluate condition.
let conditionMet;
if ( trackingCondition.field ) {
const actualValue = selectorResult[ trackingCondition.field ];
conditionMet = actualValue === trackingCondition.expectedValue;
} else {
const boolResult = !! selectorResult;
const expectedBool = !! trackingCondition.expectedValue;
conditionMet = boolResult === expectedBool;
}
registration.lastConditionResult = conditionMet;
return conditionMet;
} catch ( error ) {
return false;
}
}
/**
* Handle condition state changes for a funnel.
* @param {Object} registration - Funnel registration.
* @param {boolean} conditionMet - Whether condition is currently met.
* @return {boolean} Whether funnel should be active.
*/
handleConditionChange( registration, conditionMet ) {
// Handle deactivation.
if ( ! conditionMet && registration.isActive ) {
this.resetFunnelState( registration );
return false;
}
// Handle reactivation.
if ( conditionMet && ! registration.isActive ) {
this.resetFunnelState( registration );
}
return conditionMet;
}
/**
* Reset funnel state for clean reinitialization.
* @param {Object} registration - Funnel registration.
*/
resetFunnelState( registration ) {
registration.isActive = false;
registration.hasTrackedPageLoad = false;
registration.initializationAttempts = 0;
registration.previousValues = {};
}
/**
* Check if store is ready for tracking.
* @param {Object} store - Store object.
* @param {Object} registration - Funnel registration.
* @return {boolean} Whether store is ready.
*/
isStoreReadyForTracking( store, registration ) {
const isReady = store.transientData?.()?.isReady;
const hasHydrationSource = store.getAllFieldSources?.()?.hydrate;
const hasFieldSourceMethods =
typeof store.getFieldSource === 'function' &&
typeof store.getAllFieldSources === 'function';
if ( ! hasFieldSourceMethods ) {
return isReady || registration.initializationAttempts > 50;
}
if ( isReady ) {
return true;
}
if ( hasHydrationSource ) {
return true;
}
return registration.initializationAttempts > 50;
}
/**
* Initialize previous values for a funnel.
* @param {Function} select - WordPress data select function.
* @param {string} storeName - Store name.
* @param {Array} fieldConfigs - Field configurations.
* @param {Object} previousValues - Previous values object to populate.
*/
initializePreviousValues(
select,
storeName,
fieldConfigs,
previousValues
) {
fieldConfigs.forEach( ( fieldConfig ) => {
try {
const currentValue = getFieldValue(
select,
storeName,
fieldConfig
);
previousValues[ fieldConfig.fieldName ] = currentValue;
} catch ( error ) {
console.error(
`[SubscriptionManager] Error initializing ${ fieldConfig.fieldName }:`,
error
);
}
} );
}
/**
* Track initial page load.
* @param {Function} select - WordPress data select function.
* @param {string} storeName - Store name.
* @param {Object} trackingService - Tracking service.
* @param {Object} registration - Funnel registration object.
*/
trackInitialPageLoad( select, storeName, trackingService, registration ) {
try {
const persistentData = select( storeName ).persistentData?.();
const currentStep = persistentData?.step;
if ( typeof currentStep === 'number' ) {
const metadata = this.createFunnelMetadata(
select,
registration
);
trackingService.processStateChange( {
field: 'step',
oldValue: null,
newValue: currentStep,
action: {
type: 'PAGE_LOAD',
payload: { step: currentStep },
source: 'system',
},
metadata,
} );
}
} catch ( error ) {
console.error(
`[SubscriptionManager] Error tracking page load for ${ storeName }:`,
error
);
}
}
/**
* Check if should track page loads for a store.
* @param {string} storeName - Store name.
* @return {boolean} Whether to track page loads.
*/
shouldTrackPageLoad( storeName ) {
return (
storeName.includes( 'onboarding' ) || storeName.includes( 'wizard' )
);
}
/**
* Process field changes for a specific funnel.
* @param {Function} select - WordPress data select function.
* @param {Object} store - Store object.
* @param {string} storeName - Store name.
* @param {Object} registration - Funnel registration.
* @param {Array} fieldConfigs - Field configurations.
* @param {Object} fieldRules - Field rules.
* @param {Object} trackingService - Tracking service.
*/
processFieldChangesForFunnel(
select,
store,
storeName,
registration,
fieldConfigs,
fieldRules,
trackingService
) {
fieldConfigs.forEach( ( fieldConfig ) => {
try {
const currentValue = getFieldValue(
select,
storeName,
fieldConfig
);
const previousValue =
registration.previousValues[ fieldConfig.fieldName ];
// Skip if no change.
if ( currentValue === previousValue ) {
return;
}
// Get field source.
const fieldSource =
store.getFieldSource?.( fieldConfig.fieldName )?.source ||
'';
// Check if this source should be tracked for this funnel.
const shouldTrack = shouldTrackFieldSource(
fieldConfig.fieldName,
fieldSource,
fieldRules
);
if ( ! shouldTrack ) {
// Update previous value but don't track.
registration.previousValues[ fieldConfig.fieldName ] =
currentValue;
return;
}
// Process tracked change for this funnel.
this.processTrackedChangeForFunnel(
fieldConfig,
previousValue,
currentValue,
fieldSource,
trackingService,
select,
storeName,
registration
);
// Update previous value.
registration.previousValues[ fieldConfig.fieldName ] =
currentValue;
} catch ( error ) {
console.error(
`[SubscriptionManager] Error processing field ${ fieldConfig.fieldName } for funnel ${ registration.funnelId }:`,
error
);
}
} );
}
/**
* Process a tracked change for a specific funnel.
* @param {Object} fieldConfig - Field configuration.
* @param {*} oldValue - Previous value.
* @param {*} newValue - New value.
* @param {string} source - Field source.
* @param {Object} trackingService - Tracking service.
* @param {Function} select - WordPress data select function.
* @param {string} storeName - Store name.
* @param {Object} registration - Funnel registration object.
*/
processTrackedChangeForFunnel(
fieldConfig,
oldValue,
newValue,
source,
trackingService,
select,
storeName,
registration
) {
const metadata = this.createFunnelMetadata( select, registration );
const action = {
type:
fieldConfig.type === 'transient'
? 'SET_TRANSIENT'
: 'SET_PERSISTENT',
payload: { [ fieldConfig.fieldName ]: newValue },
source,
};
trackingService.processStateChange( {
field: fieldConfig.fieldName,
oldValue,
newValue,
action,
metadata: {
...metadata,
detectedSource: source,
},
} );
}
/**
* Create aggregated metadata from all stores tracked by a funnel.
* @param {Function} select - WordPress data select function.
* @param {Object} registration - Funnel registration object.
* @return {Object} Aggregated metadata from all funnel stores.
*/
createFunnelMetadata( select, registration ) {
try {
// Start with base metadata.
const metadata = {
action: 'SUBSCRIBER_CHANGE',
timestamp: Date.now(),
funnelId: registration.funnelId,
};
// Get all stores this funnel tracks from the registration.
const funnelStores = this.getFunnelStores( registration.funnelId );
// Aggregate data from each store.
funnelStores.forEach( ( storeName ) => {
try {
const store = select( storeName );
if ( ! store ) {
return;
}
// Get store data safely.
const flags = this.safeStoreCall( store, 'flags', {} );
const persistentData = this.safeStoreCall(
store,
'persistentData',
{}
);
const transientData = this.safeStoreCall(
store,
'transientData',
{}
);
// Add store-specific metadata with store prefix to avoid conflicts.
const storeKey = storeName.replace( 'wc/paypal/', '' );
metadata[ `${ storeKey }_flags` ] = flags;
metadata[ `${ storeKey }_isReady` ] = transientData.isReady;
// Spread all store data, with later stores potentially overriding earlier ones.
// This maintains backward compatibility with existing translation functions.
Object.assign( metadata, persistentData, transientData );
// Keep track of which stores contributed data.
if ( ! metadata.contributingStores ) {
metadata.contributingStores = [];
}
metadata.contributingStores.push( storeName );
} catch ( error ) {
console.warn(
`[SubscriptionManager] Error getting metadata from store ${ storeName }:`,
error
);
}
} );
// Add step information after aggregation
this.enhanceMetadataWithStepInfo( metadata, registration );
return metadata;
} catch ( error ) {
console.error(
`[SubscriptionManager] Error creating funnel metadata for ${ registration.funnelId }:`,
error
);
return {
error: 'funnel_metadata_creation_failed',
errorMessage: error.message,
timestamp: Date.now(),
funnelId: registration.funnelId,
};
}
}
/**
* Enhance metadata with step information from funnel configuration
* @param {Object} metadata - The metadata object to enhance
* @param {Object} registration - Funnel registration object
*/
enhanceMetadataWithStepInfo( metadata, registration ) {
try {
// Get the step number from aggregated data
const stepNumber = metadata.step;
// Get step info directly from registration
const stepInfo = registration.stepInfo || {};
// Add step name if we can map it
if ( typeof stepNumber === 'number' && stepInfo[ stepNumber ] ) {
const stepData = stepInfo[ stepNumber ];
metadata.stepName =
typeof stepData === 'string' ? stepData : stepData.name;
}
// Add currentStep alias for backward compatibility with existing translations
metadata.currentStep = stepNumber;
// Ensure step fields are properly typed (not string 'null')
if ( stepNumber === null || stepNumber === undefined ) {
metadata.step = null;
metadata.currentStep = null;
}
// Debug logging
console.log(
`[SubscriptionManager] Step enhancement debug for ${ registration.funnelId }:`,
{
stepNumber,
stepNumberType: typeof stepNumber,
currentStep: metadata.currentStep,
stepName: metadata.stepName,
stepInfo,
stepInfoKeys: Object.keys( stepInfo ),
allMetadataKeys: Object.keys( metadata ),
contributingStores: metadata.contributingStores,
}
);
} catch ( error ) {
console.warn(
`[SubscriptionManager] Error enhancing metadata with step info:`,
error
);
}
}
/**
* Get all stores tracked by a specific funnel.
* @param {string} funnelId - The funnel ID.
* @return {Array} Array of store names.
*/
getFunnelStores( funnelId ) {
const stores = [];
Object.entries( this.storeRegistrations ).forEach(
( [ storeName, registrations ] ) => {
if (
registrations.some( ( reg ) => reg.funnelId === funnelId )
) {
stores.push( storeName );
}
}
);
return stores;
}
/**
* Safely call a store method with fallback (moved from utils).
* @param {Object} store - The store object.
* @param {string} method - The method name to call.
* @param {*} fallback - Fallback value if method fails.
* @return {*} Method result or fallback.
*/
safeStoreCall( store, method, fallback = null ) {
try {
if ( typeof store[ method ] === 'function' ) {
const result = store[ method ]();
return result !== undefined ? result : fallback;
}
return fallback;
} catch ( error ) {
return fallback;
}
}
/**
* Handle field source clearing after all funnels have processed.
* @param {string} storeName - Store name.
* @param {Object} store - Store object.
* @param {Array} registrations - All funnel registrations for this store.
*/
handleFieldSourceClearing( storeName, store, registrations ) {
if ( ! store.clearFieldSource || ! store.getAllFieldSources ) {
return;
}
try {
// Get all field sources that were processed.
const allFieldSources = store.getAllFieldSources() || {};
// Collect all field names that any active funnel is tracking.
const trackedFields = new Set();
registrations.forEach( ( registration ) => {
if ( registration.isActive ) {
registration.fieldConfigs.forEach( ( fieldConfig ) => {
trackedFields.add( fieldConfig.fieldName );
} );
}
} );
// Clear sources for fields that were tracked.
Object.keys( allFieldSources ).forEach( ( fieldName ) => {
if ( trackedFields.has( fieldName ) ) {
store.clearFieldSource( fieldName );
}
} );
} catch ( error ) {
console.error(
`[SubscriptionManager] Error clearing field sources for ${ storeName }:`,
error
);
}
}
/**
* Unregister a funnel from a store.
* @param {string} storeName - Store name.
* @param {string} funnelId - Funnel ID.
*/
unregisterFunnelForStore( storeName, funnelId ) {
const registrations = this.storeRegistrations[ storeName ];
if ( ! registrations ) {
return;
}
const index = registrations.findIndex(
( reg ) => reg.funnelId === funnelId
);
if ( index >= 0 ) {
registrations.splice( index, 1 );
if ( this.debugMode ) {
console.log(
`[SubscriptionManager] Unregistered funnel ${ funnelId } from store ${ storeName }. ` +
`Remaining funnels: ${ registrations.length }`
);
}
// If no more funnels for this store, clean up subscription.
if ( registrations.length === 0 ) {
this.cleanupStoreSubscription( storeName );
}
}
}
/**
* Clean up subscription for a store.
* @param {string} storeName - Store name.
*/
cleanupStoreSubscription( storeName ) {
const subscription = this.storeSubscriptions[ storeName ];
if ( subscription ) {
subscription.unsubscribe();
delete this.storeSubscriptions[ storeName ];
delete this.storeRegistrations[ storeName ];
if ( this.debugMode ) {
console.log(
`[SubscriptionManager] Cleaned up subscription for store ${ storeName }`
);
}
}
}
/**
* Get status information for debugging.
* @return {Object} Status information.
*/
getStatus() {
const status = {
storesTracked: Object.keys( this.storeSubscriptions ).length,
activeSubscriptions: Object.keys( this.storeSubscriptions ).filter(
( storeName ) => this.storeSubscriptions[ storeName ].isActive
).length,
totalFunnelRegistrations: 0,
storeDetails: {},
};
Object.entries( this.storeRegistrations ).forEach(
( [ storeName, registrations ] ) => {
status.totalFunnelRegistrations += registrations.length;
status.storeDetails[ storeName ] = {
funnelCount: registrations.length,
funnels: registrations.map( ( reg ) => ( {
funnelId: reg.funnelId,
isActive: reg.isActive,
conditionMet: reg.lastConditionResult,
conditionChecks: reg.conditionCheckCount,
} ) ),
};
}
);
return status;
}
}
export const subscriptionManager = new SubscriptionManager();

View file

@ -0,0 +1,108 @@
/**
* Utilities: Essential tracking helper functions.
*
* Provides helper functions for the tracking system.
* Includes field validation, metadata creation, and store utilities.
*
* @file
*/
/**
* Determines if a field with a given source should be tracked.
*
* @param {string} fieldName - The name of the field.
* @param {string} source - The source of the field change.
* @param {Object} storeRules - Rules for the specific store.
* @return {boolean} Whether the field should be tracked.
*/
export function shouldTrackFieldSource( fieldName, source, storeRules ) {
if ( ! source ) {
return false;
}
if ( ! storeRules ) {
return false;
}
if ( ! storeRules[ fieldName ] ) {
return false;
}
// Get and validate field rules.
const fieldRule = storeRules[ fieldName ];
const allowedSources = fieldRule.allowedSources;
if ( ! Array.isArray( allowedSources ) ) {
console.warn(
`[TRACK CHECK] Invalid allowedSources for ${ fieldName }:`,
{ allowedSources, type: typeof allowedSources }
);
return false;
}
if ( allowedSources.length === 0 ) {
return false;
}
// Check if source is allowed.
return allowedSources.includes( source );
}
/**
* Get the value of a field from the store with comprehensive error handling.
*
* @param {Function} select - WordPress data select function.
* @param {string} storeName - The name of the store.
* @param {Object} fieldConfig - Configuration for the field.
* @return {*} The field value
*/
export function getFieldValue( select, storeName, fieldConfig ) {
try {
// Use custom selector if provided.
if ( typeof fieldConfig.selector === 'function' ) {
return fieldConfig.selector( select, storeName );
}
// Get the store.
const store = select( storeName );
if ( ! store ) {
return undefined;
}
// Determine data source.
const dataType = fieldConfig.type || 'persistent';
let data;
if ( dataType === 'persistent' ) {
data = store.persistentData?.();
} else if ( dataType === 'transient' ) {
data = store.transientData?.();
} else {
console.warn( `[FIELD VALUE] Unknown data type: ${ dataType }` );
return undefined;
}
if ( ! data || typeof data !== 'object' ) {
return undefined;
}
// Handle nested field paths.
const fieldPath = fieldConfig.fieldName.split( '.' );
let value = data;
for ( const key of fieldPath ) {
if ( value === null || value === undefined ) {
return undefined;
}
value = value[ key ];
}
return value;
} catch ( error ) {
console.error(
`[FIELD VALUE] Error getting value for ${ fieldConfig.fieldName }:`,
error
);
return undefined;
}
}

View file

@ -0,0 +1,475 @@
/**
* Field Configuration Helpers: Generic utilities for creating field tracking configurations.
*
* Provides helper functions and builder patterns for defining field tracking rules.
* Includes validation helpers, transform patterns, and configuration builders for funnels.
*
* @file
*/
/**
* Create a standard field tracking configuration.
* @param {string} fieldName - The name of the field.
* @param {string} type - The type of field ('persistent' or 'transient').
* @param {Object} options - Additional configuration options.
* @return {Object} Field tracking configuration object.
*/
export const createFieldTrackingConfig = (
fieldName,
type = 'persistent',
options = {}
) => {
return {
fieldName,
type,
selector:
options.selector ||
( ( select, storeName ) => {
const data =
type === 'persistent'
? select( storeName ).persistentData()
: select( storeName ).transientData();
return data?.[ fieldName ];
} ),
rules: {
allowedSources: [ 'user' ], // Default to user-only.
...options.rules,
},
...options,
};
};
/**
* Create a field tracking config that allows both user and system sources.
* @param {string} fieldName - The name of the field.
* @param {string} type - The type of field ('persistent' or 'transient').
* @param {Object} options - Additional configuration options.
* @return {Object} Field tracking configuration object.
*/
export const createSystemFieldTrackingConfig = (
fieldName,
type = 'persistent',
options = {}
) => {
return createFieldTrackingConfig( fieldName, type, {
...options,
rules: {
allowedSources: [ 'user', 'system' ],
...options.rules,
},
} );
};
/**
* Create a transient field tracking config (for fields stored in transientData).
* @param {string} fieldName - The name of the field.
* @param {Object} options - Additional configuration options.
* @return {Object} Field tracking configuration object.
*/
export const createTransientFieldTrackingConfig = (
fieldName,
options = {}
) => {
return createFieldTrackingConfig( fieldName, 'transient', options );
};
/**
* Create multiple field tracking configs with the same base configuration.
* @param {Array} fieldNames - Array of field names.
* @param {string} type - The type of field ('persistent' or 'transient').
* @param {Object} baseOptions - Base configuration options.
* @return {Array} Array of field tracking configuration objects.
*/
export const createFieldTrackingConfigs = (
fieldNames,
type = 'persistent',
baseOptions = {}
) => {
return fieldNames.map( ( fieldName ) =>
createFieldTrackingConfig( fieldName, type, baseOptions )
);
};
/**
* Create a tracking config for nested field data.
* @param {string} fieldName - The name of the field.
* @param {string} path - Dot-separated path to nested field.
* @param {string} type - The type of field ('persistent' or 'transient').
* @param {Object} options - Additional configuration options.
* @return {Object} Field tracking configuration object.
*/
export const createNestedFieldTrackingConfig = (
fieldName,
path,
type = 'persistent',
options = {}
) => {
return createFieldTrackingConfig( fieldName, type, {
...options,
selector: ( select, storeName ) => {
const data =
type === 'persistent'
? select( storeName ).persistentData()
: select( storeName ).transientData();
return path
.split( '.' )
.reduce( ( obj, key ) => obj?.[ key ], data );
},
} );
};
/**
* Create a tracking config for boolean fields with value mapping.
* @param {string} fieldName - The name of the field.
* @param {string} type - The type of field ('persistent' or 'transient').
* @param {string} trueValue - Value to use for true.
* @param {string} falseValue - Value to use for false.
* @return {Object} Field tracking configuration object.
*/
export const createBooleanFieldTrackingConfig = (
fieldName,
type = 'persistent',
trueValue = 'enabled',
falseValue = 'disabled'
) => {
return createFieldTrackingConfig( fieldName, type, {
transform: ( value ) => {
let selectedValue;
if ( value === true ) {
selectedValue = trueValue;
} else if ( value === false ) {
selectedValue = falseValue;
} else {
selectedValue = 'not_selected';
}
return { selected_value: selectedValue };
},
} );
};
/**
* Create a tracking config for array fields.
* @param {string} fieldName - The name of the field.
* @param {string} type - The type of field ('persistent' or 'transient').
* @param {Object} options - Additional configuration options.
* @return {Object} Field tracking configuration object.
*/
export const createArrayFieldTrackingConfig = (
fieldName,
type = 'persistent',
options = {}
) => {
return createFieldTrackingConfig( fieldName, type, {
...options,
transform: ( value ) => ( {
selected_items: Array.isArray( value ) ? value.join( ',' ) : 'none',
items_count: Array.isArray( value ) ? value.length : 0,
...( options.transform ? options.transform( value ) : {} ),
} ),
} );
};
/**
* Create a tracking config for trigger fields (like button clicks).
* @param {string} fieldName - The name of the field.
* @param {string} type - The type of field ('persistent' or 'transient').
* @param {Object} options - Additional configuration options.
* @return {Object} Field tracking configuration object.
*/
export const createTriggerFieldTrackingConfig = (
fieldName,
type = 'transient',
options = {}
) => {
return createFieldTrackingConfig( fieldName, type, {
...options,
// Typically used with translation that checks oldValue === false && newValue === true.
} );
};
/**
* Helper to create store field tracking configurations.
* @param {string} storeName - Name of the store.
* @param {Array|Object} fieldConfigs - Array of field configurations or single config.
* @return {Object} Store tracking configuration object.
*/
export const createStoreTrackingConfig = ( storeName, fieldConfigs ) => {
return {
[ storeName ]: Array.isArray( fieldConfigs )
? fieldConfigs
: [ fieldConfigs ],
};
};
/**
* Merge multiple store tracking configurations.
* @param {...Object} configs - Store tracking configuration objects to merge.
* @return {Object} Merged store tracking configuration.
*/
export const mergeStoreTrackingConfigs = ( ...configs ) => {
return Object.assign( {}, ...configs );
};
/**
* Generic configuration builder for any funnel.
*/
export class FunnelConfigBuilder {
/**
* Create a new funnel configuration builder.
* @param {string} funnelId - Unique identifier for the funnel.
*/
constructor( funnelId ) {
this.funnelId = funnelId;
this.config = {
debug: false,
adapters: [ 'console' ],
eventPrefix: funnelId,
events: {},
translations: {},
stepInfo: {},
fieldConfigs: {},
};
}
/**
* Set debug mode.
* @param {boolean} debug - Whether to enable debug mode.
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
setDebug( debug = true ) {
this.config.debug = debug;
return this;
}
/**
* Set adapters for tracking.
* @param {Array} adapters - Array of adapter names.
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
setAdapters( adapters ) {
this.config.adapters = adapters;
return this;
}
/**
* Set event prefix.
* @param {string} prefix - Prefix for event names.
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
setEventPrefix( prefix ) {
this.config.eventPrefix = prefix;
return this;
}
/**
* Add events to the configuration.
* @param {Object} events - Object mapping event names to event identifiers.
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
addEvents( events ) {
this.config.events = { ...this.config.events, ...events };
return this;
}
/**
* Add translations to the configuration.
* @param {Object} translations - Object mapping field names to translation functions.
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
addTranslations( translations ) {
this.config.translations = {
...this.config.translations,
...translations,
};
return this;
}
/**
* Add step information to the configuration.
* @param {Object} stepInfo - Object mapping step numbers to step metadata.
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
addStepInfo( stepInfo ) {
this.config.stepInfo = { ...this.config.stepInfo, ...stepInfo };
return this;
}
/**
* Set tracking condition for the funnel.
* @param {Object} condition - Tracking condition configuration.
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
setTrackingCondition( condition ) {
this.config.trackingCondition = condition;
return this;
}
/**
* Add store tracking configuration.
* @param {string} storeName - Name of the store.
* @param {Array} fieldTrackingConfigs - Array of field tracking configurations.
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
addStore( storeName, fieldTrackingConfigs ) {
this.config.fieldConfigs[ storeName ] = fieldTrackingConfigs;
return this;
}
/**
* Merge additional configuration.
* @param {Object} additionalConfig - Additional configuration to merge
* @return {FunnelConfigBuilder} Builder instance for chaining.
*/
mergeConfig( additionalConfig ) {
this.config = { ...this.config, ...additionalConfig };
return this;
}
/**
* Build the final configuration.
* @return {Object} Complete funnel configuration.
*/
build() {
return this.config;
}
/**
* Factory method for typical funnel setups.
* @param {string} funnelId - Unique identifier for the funnel.
* @param {Object} options - Configuration options.
* @return {FunnelConfigBuilder} New builder instance.
*/
static createBasicFunnel( funnelId, options = {} ) {
const builder = new FunnelConfigBuilder( funnelId )
.setDebug( options.debug || false )
.setAdapters( options.adapters || [ 'console' ] );
if ( options.eventPrefix ) {
builder.setEventPrefix( options.eventPrefix );
}
if ( options.trackingCondition ) {
builder.setTrackingCondition( options.trackingCondition );
}
return builder;
}
}
/**
* Common field tracking rule patterns.
*/
export const RulePatterns = {
// Only user interactions.
userOnly: { allowedSources: [ 'user' ] },
// User interactions and system changes.
userAndSystem: { allowedSources: [ 'user', 'system' ] },
// Only system changes.
systemOnly: { allowedSources: [ 'system' ] },
// Custom rule creator.
custom: ( sources ) => ( { allowedSources: sources } ),
};
/**
* Common transform patterns for field tracking.
*/
export const TransformPatterns = {
// Boolean to enabled/disabled.
enabledDisabled: ( value ) => {
let selectedValue;
if ( value === true ) {
selectedValue = 'enabled';
} else if ( value === false ) {
selectedValue = 'disabled';
} else {
selectedValue = 'not_selected';
}
return { selected_value: selectedValue };
},
// Boolean to yes/no.
yesNo: ( value ) => {
let selectedValue;
if ( value === true ) {
selectedValue = 'yes';
} else if ( value === false ) {
selectedValue = 'no';
} else {
selectedValue = 'not_selected';
}
return { selected_value: selectedValue };
},
// Array to comma-separated string with count.
arrayWithCount: ( value ) => ( {
selected_items: Array.isArray( value ) ? value.join( ',' ) : 'none',
items_count: Array.isArray( value ) ? value.length : 0,
} ),
// Just the raw value.
passthrough: ( value ) => ( { selected_value: value } ),
// Custom transform creator.
custom: ( transformFn ) => transformFn,
};
/**
* Validation helpers.
*/
export const ValidationHelpers = {
// Validate field tracking configuration.
validateFieldTrackingConfig: ( fieldConfig ) => {
const errors = [];
if ( ! fieldConfig.fieldName ) {
errors.push( 'fieldName is required' );
}
if ( ! [ 'persistent', 'transient' ].includes( fieldConfig.type ) ) {
errors.push( 'type must be "persistent" or "transient"' );
}
if ( fieldConfig.rules && ! fieldConfig.rules.allowedSources ) {
errors.push(
'rules.allowedSources is required when rules are specified'
);
}
return { valid: errors.length === 0, errors };
},
// Validate store tracking configuration.
validateStoreTrackingConfig: ( storeConfig ) => {
const errors = [];
Object.entries( storeConfig ).forEach(
( [ storeName, fieldConfigs ] ) => {
if ( ! Array.isArray( fieldConfigs ) ) {
errors.push(
`Field tracking configs for store ${ storeName } must be an array`
);
return;
}
fieldConfigs.forEach( ( fieldConfig, index ) => {
const validation =
ValidationHelpers.validateFieldTrackingConfig(
fieldConfig
);
if ( ! validation.valid ) {
errors.push(
`Store ${ storeName }, field config ${ index }: ${ validation.errors.join(
', '
) }`
);
}
} );
}
);
return { valid: errors.length === 0, errors };
},
};