Merge pull request #3446 from woocommerce/PCP-4649-track-wizard-screen-views

Add WooCommerce Tracks integration and the Onboarding funnel (4649)
This commit is contained in:
Emili Castells 2025-06-20 15:37:11 +02:00 committed by GitHub
commit ed894b17fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3696 additions and 118 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

@ -4,6 +4,7 @@ import { __ } from '@wordpress/i18n';
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';
import { Notice } from '../../../ReusableComponents/Elements';
@ -19,12 +20,13 @@ const useIsFirefox = () => {
* 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,
@ -32,6 +34,7 @@ const ButtonOrPlaceholder = ( {
showIcon,
href,
children,
onClick,
} ) => {
const isFirefox = useIsFirefox();
@ -39,6 +42,7 @@ const ButtonOrPlaceholder = ( {
className,
variant,
icon: showIcon ? OpenSignup : null,
onClick,
};
if ( href ) {
@ -77,12 +81,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();
@ -107,6 +127,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

@ -13,6 +13,47 @@ const StepProducts = () => {
const { isCasualSeller } = OnboardingHooks.useBusiness();
useEffect( () => {
const productChoicesFull = [
{
value: PRODUCT_TYPES.VIRTUAL,
title: __( 'Virtual', 'woocommerce-paypal-payments' ),
description: __(
'Items do not require shipping.',
'woocommerce-paypal-payments'
),
contents: <DetailsVirtual />,
},
{
value: PRODUCT_TYPES.PHYSICAL,
title: __( 'Physical Goods', 'woocommerce-paypal-payments' ),
description: __(
'Items require shipping.',
'woocommerce-paypal-payments'
),
contents: <DetailsPhysical />,
},
{
value: PRODUCT_TYPES.SUBSCRIPTIONS,
title: __( 'Subscriptions', 'woocommerce-paypal-payments' ),
description: __(
'Recurring payments for either physical goods or services.',
'woocommerce-paypal-payments'
),
isDisabled: isCasualSeller,
contents: (
/*
* Note: The link should be only displayed if the subscriptions plugin is not installed.
* But when the plugin is not active, this option is completely hidden;
* This means: In the current configuration, we never show the link.
*/
<DetailsSubscriptions
showLink={ false }
showNotice={ isCasualSeller }
/>
),
},
];
const initChoices = () => {
const choices = productChoicesFull.map( ( choice ) => {
if (
@ -38,7 +79,7 @@ const StepProducts = () => {
};
initChoices();
}, [ canUseSubscriptions, optionState, products, setProducts ] );
}, [ canUseSubscriptions, isCasualSeller ] );
const handleChange = ( key, checked ) => {
const getNewValue = () => {
@ -48,48 +89,9 @@ const StepProducts = () => {
return products.filter( ( val ) => val !== key );
};
setProducts( getNewValue() );
setProducts( getNewValue(), 'user' );
};
const productChoicesFull = [
{
value: PRODUCT_TYPES.VIRTUAL,
title: __( 'Virtual', 'woocommerce-paypal-payments' ),
description: __(
'Items do not require shipping.',
'woocommerce-paypal-payments'
),
contents: <DetailsVirtual />,
},
{
value: PRODUCT_TYPES.PHYSICAL,
title: __( 'Physical Goods', 'woocommerce-paypal-payments' ),
description: __(
'Items require shipping.',
'woocommerce-paypal-payments'
),
contents: <DetailsPhysical />,
},
{
value: PRODUCT_TYPES.SUBSCRIPTIONS,
title: __( 'Subscriptions', 'woocommerce-paypal-payments' ),
description: __(
'Recurring payments for either physical goods or services.',
'woocommerce-paypal-payments'
),
isDisabled: isCasualSeller,
contents: (
/*
* Note: The link should be only displayed if the subscriptions plugin is not installed.
* But when the plugin is not active, this option is completely hidden;
* This means: In the current configuration, we never show the link.
*/
<DetailsSubscriptions
showLink={ false }
showNotice={ isCasualSeller }
/>
),
},
];
return (
<div className="ppcp-r-page-products">
<OnboardingHeader

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

@ -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

@ -5,6 +5,9 @@ import {
SettingsStoreName,
StylingStoreName,
TodosStoreName,
PayLaterMessagingStoreName,
FeaturesStoreName,
TrackingStoreName,
} from './index';
export const addDebugTools = ( context, modules ) => {
@ -80,6 +83,9 @@ export const addDebugTools = ( context, modules ) => {
stores.push( SettingsStoreName );
stores.push( StylingStoreName );
stores.push( TodosStoreName );
stores.push( PayLaterMessagingStoreName );
stores.push( FeaturesStoreName );
stores.push( TrackingStoreName );
// Only reset the onboarding store when the wizard is not completed.
if ( ! completed ) {
@ -119,6 +125,9 @@ export const addDebugTools = ( context, modules ) => {
stores.push( StylingStoreName );
stores.push( TodosStoreName );
stores.push( OnboardingStoreName );
stores.push( PayLaterMessagingStoreName );
stores.push( FeaturesStoreName );
stores.push( TrackingStoreName );
stores.forEach( ( storeName ) => {
const store = wp.data.dispatch( storeName );

View file

@ -7,6 +7,10 @@ import * as Styling from './styling';
import * as Todos from './todos';
import * as PayLaterMessaging from './pay-later-messaging';
import * as Features from './features';
import * as Tracking from './tracking';
// Initialize tracking funnels before any store initialization.
import '../services/tracking/init';
const stores = [
Onboarding,
@ -17,6 +21,7 @@ const stores = [
Todos,
PayLaterMessaging,
Features,
Tracking,
];
stores.forEach( ( store ) => {
@ -52,6 +57,7 @@ export const StylingStoreName = Styling.STORE_NAME;
export const TodosStoreName = Todos.STORE_NAME;
export const PayLaterMessagingStoreName = PayLaterMessaging.STORE_NAME;
export const FeaturesStoreName = Features.STORE_NAME;
export const TrackingStoreName = Tracking.STORE_NAME;
export * from './configuration';

View file

@ -13,7 +13,7 @@ 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',
};

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,7 @@ const defaultTransient = Object.freeze( {
isReady: false,
manualClientId: '',
manualClientSecret: '',
connectionButtonClicked: false,
// Read only values, provided by the server.
flags: Object.freeze( {

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

@ -0,0 +1,12 @@
/**
* Action Types: Define unique identifiers for actions across all store modules.
*
* @file
*/
export default {
UPDATE_SOURCES: 'ppcp/tracking/UPDATE_SOURCES',
CLEAR_SOURCES: 'ppcp/tracking/CLEAR_SOURCES',
CLEAR_FIELD_SOURCE: 'ppcp/tracking/CLEAR_FIELD_SOURCE',
RESET: 'ppcp/tracking/RESET',
};

View file

@ -0,0 +1,66 @@
/**
* Action Creators: Define functions to create action objects.
*
* These functions update state or trigger side effects (e.g., async operations).
* Two main actions: updateSources and clearSources.
*
* @file
*/
import ACTION_TYPES from './action-types';
/**
* Updates the source tracking information for a specific field.
*
* Records when and from where a field value was changed, enabling.
* audit trails and debugging capabilities for state modifications.
*
* @param {string} storeName - Name of the store containing the field.
* @param {string} fieldName - Name of the field being tracked.
* @param {string} source - Source identifier for the change (e.g., 'user', 'system').
* @return {Object} Action object with type UPDATE_SOURCES and tracking payload.
*/
export const updateSources = ( storeName, fieldName, source ) => ( {
type: ACTION_TYPES.UPDATE_SOURCES,
payload: {
storeName,
fieldName,
source,
timestamp: Date.now(),
},
} );
/**
* Clears source tracking information for fields or stores.
*
* Can clear tracking for a specific field, all fields in a store,
* or all tracking data.
*
* @param {string|null} storeName - Name of the store (optional, null clears all stores).
* @param {string|null} fieldName - Name of the field (optional, null clears all fields in store).
* @return {Object} Action object with appropriate clear type and payload.
*/
export const clearSources = ( storeName = null, fieldName = null ) => {
if ( fieldName ) {
return {
type: ACTION_TYPES.CLEAR_FIELD_SOURCE,
payload: { storeName, fieldName },
};
}
return {
type: ACTION_TYPES.CLEAR_SOURCES,
payload: { storeName },
};
};
/**
* Resets all source tracking data.
*
* Clears all stored tracking information across all stores and fields,
* returning the tracking store to its initial state.
*
* @return {Object} Action object with type RESET.
*/
export const reset = () => ( {
type: ACTION_TYPES.RESET,
} );

View file

@ -0,0 +1,12 @@
/**
* Constants: Field source tracking store.
*
* @file
*/
/**
* Name of the field source tracking store.
*
* @type {string}
*/
export const STORE_NAME = 'wc/paypal/tracking';

View file

@ -0,0 +1,33 @@
/**
* Store setup: Field source tracking store.
*
* Initializes and registers the field source tracking store.
*
* @file
*/
import { createReduxStore, register } from '@wordpress/data';
import { STORE_NAME } from './constants';
import reducer from './reducer';
import * as selectors from './selectors';
import * as actions from './actions';
/**
* Initialize the field source tracking store.
*
* @return {boolean} True if initialization succeeded
*/
export const initStore = () => {
const store = createReduxStore( STORE_NAME, {
reducer,
actions,
selectors,
} );
register( store );
return Boolean( wp.data.select( STORE_NAME ) );
};
export { selectors, STORE_NAME };

View file

@ -0,0 +1,64 @@
/**
* Reducer: Field source tracking store.
*
* State structure: { storeName: { fieldName: { source, timestamp } } }
*
* @file
*/
import ACTION_TYPES from './action-types';
const initialState = {};
const trackingReducer = ( state = initialState, action ) => {
switch ( action.type ) {
case ACTION_TYPES.UPDATE_SOURCES: {
const { storeName, fieldName, source, timestamp } = action.payload;
return {
...state,
[ storeName ]: {
...( state[ storeName ] || {} ),
[ fieldName ]: { source, timestamp },
},
};
}
case ACTION_TYPES.CLEAR_FIELD_SOURCE: {
const { storeName, fieldName } = action.payload;
const storeData = state[ storeName ];
if ( ! storeData ) {
return state;
}
const newStoreData = { ...storeData };
delete newStoreData[ fieldName ];
return {
...state,
[ storeName ]: newStoreData,
};
}
case ACTION_TYPES.CLEAR_SOURCES: {
const { storeName } = action.payload;
if ( storeName ) {
const newState = { ...state };
delete newState[ storeName ];
return newState;
}
return initialState;
}
case ACTION_TYPES.RESET:
return initialState;
default:
return state;
}
};
export default trackingReducer;

View file

@ -0,0 +1,52 @@
/**
* Selectors: Field source tracking store.
*
* Accessors for field source information.
*
* @file
*/
/**
* Get field source for specific field.
*
* @param {Object} state - Store state.
* @param {string} storeName - Name of the store.
* @param {string} fieldName - Name of the field.
* @return {Object|null} Source information or null.
*/
export const getFieldSource = ( state, storeName, fieldName ) => {
return state?.[ storeName ]?.[ fieldName ] || null;
};
/**
* Get all field sources for a store.
*
* @param {Object} state - Store state.
* @param {string} storeName - Name of the store.
* @return {Object} All field sources for the store.
*/
export const getStoreFieldSources = ( state, storeName ) => {
return state?.[ storeName ] || {};
};
/**
* Get all field sources across all stores.
*
* @param {Object} state - Store state.
* @return {Object} All field sources.
*/
export const getAllFieldSources = ( state ) => {
return state || {};
};
/**
* Check if field is tracked.
*
* @param {Object} state - Store state.
* @param {string} storeName - Name of the store.
* @param {string} fieldName - Name of the field.
* @return {boolean} True if field is tracked.
*/
export const isFieldTracked = ( state, storeName, fieldName ) => {
return !! getFieldSource( state, storeName, fieldName );
};

View file

@ -1,6 +1,17 @@
/**
* Utility functions for store management and hooks.
*
* Provides core functionality for creating reducers, managing state updates,
* and implementing custom React hooks for store interaction.
*
* @file
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { STORE_NAME as TRACKING_STORE } from '../data/tracking/constants';
/**
* Updates an object with new values, filtering based on allowed keys.
*
@ -74,7 +85,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;
@ -82,6 +97,8 @@ export const createReducer = (
};
/**
* Creates custom React hooks for accessing store state.
*
* Returns an object with two hooks:
* - useTransient( prop )
* - usePersistent( prop )
@ -89,13 +106,13 @@ export const createReducer = (
* Both hooks have a similar syntax to the native "useState( prop )" hook, but provide access to
* a transient or persistent property in the relevant Redux store.
*
* Sample:
*
* @example
* const { useTransient } = createHooksForStore( STORE_NAME );
* const [ isReady, setIsReady ] = useTransient( 'isReady' );
* setIsReady( true, 'user' ); // Optional source tracking
*
* @param {string} storeName Store name.
* @return {{useTransient, usePersistent}} Store hooks.
* @return {{useTransient: Function, usePersistent: Function}} Store hooks.
*/
export const createHooksForStore = ( storeName ) => {
const createHook = ( selector, dispatcher ) => ( key ) => {
@ -119,17 +136,32 @@ export const createHooksForStore = ( storeName ) => {
);
const actions = useDispatch( storeName );
const trackingActions = useDispatch( TRACKING_STORE );
const setValue = useCallback(
( newValue ) => {
if ( ! actions?.[ dispatcher ] ) {
throw new Error(
`Please create the action "${ dispatcher }" for store "${ storeName }"`
( newValue, source = null ) => {
try {
// Record field source before updating the store.
if ( source && trackingActions?.updateSources ) {
trackingActions.updateSources( storeName, key, source );
}
// Update the store state (triggers subscription manager).
if ( ! actions?.[ dispatcher ] ) {
throw new Error(
`Please create the action "${ dispatcher }" for store "${ storeName }"`
);
}
actions[ dispatcher ]( key, newValue );
} catch ( error ) {
console.error(
`Error updating ${ key } in ${ storeName }:`,
error
);
}
actions[ dispatcher ]( key, newValue );
},
[ actions, key ]
[ actions, key, trackingActions ]
);
return [ value, setValue ];

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,309 @@
/**
* 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',
} ),
rules: {
allowedSources: [ 'user' ],
},
} );
};
const createProductsTrackingConfig = () => {
return createArrayFieldTrackingConfig( 'products', 'persistent', {
rules: {
allowedSources: [ 'user' ],
},
} );
};
const createPaymentOptionsTrackingConfig = () => {
return createFieldTrackingConfig(
'areOptionalPaymentMethodsEnabled',
'persistent',
{
transform: ( value ) => ( {
selected_value: value === true ? 'expanded' : 'no_cards',
} ),
rules: {
allowedSources: [ 'user' ],
},
}
);
};
const createCompletedTrackingConfig = () => {
return createFieldTrackingConfig( 'completed', 'persistent', {
transform: ( value ) => ( {
completed: value === true,
} ),
rules: {
allowedSources: [ 'system' ],
},
} );
};
const createConnectionButtonTrackingConfig = () => {
return createTransientFieldTrackingConfig( 'connectionButtonClicked' );
};
const createSandboxTrackingConfig = () => {
return createBooleanFieldTrackingConfig(
'useSandbox',
'persistent',
'enabled',
'disabled'
);
};
const createManualConnectionTrackingConfig = () => {
return createBooleanFieldTrackingConfig(
'useManualConnection',
'persistent',
'enabled',
'disabled'
);
};
// 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,313 @@
/**
* 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 );
}
// No rules = accept all sources.
return true;
}
/**
* 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,781 @@
/**
* 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 { 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
);
}
} );
} 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 { 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
);
}
/**
* Process field changes for a specific funnel.
* Retrieves field sources from the tracking store to determine tracking eligibility.
* @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 from the tracking store.
const trackingStore = select( 'wc/paypal/tracking' );
const fieldSource =
trackingStore?.getFieldSource?.(
storeName,
fieldConfig.fieldName
)?.source || '';
// Check if this source should be tracked for this funnel.
const shouldTrack = trackingService.shouldTrackFieldSource(
fieldConfig.fieldName,
fieldSource
);
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
);
}
} );
}
/**
* 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;
if ( isReady ) {
return true;
}
// Fallback for stores that take time to initialize.
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 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;
}
} 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.
* @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;
}
}
/**
* 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,345 @@
# Tracking System Architecture
## Overview
The tracking system provides comprehensive analytics for user interactions across onboarding, settings, and other flows. It features source-based field filtering, multi-funnel support, and extensible adapter architecture to prevent tracking loops and enable granular event control.
It monitors WordPress data stores rather than adding code to frontend components, ensuring comprehensive coverage of all state changes regardless of their source (user actions, API responses, system updates) while maintaining clean separation of concerns.
## File Organization
```
src/
├── services/
│ └── tracking/
│ ├── registry.js # Central funnel registration
│ ├── subscription-manager.js # Store subscription management
│ ├── utils/
│ │ ├── field-config-helpers.js # Field config helpers & utilities
│ │ └── utils.js # Core tracking utilities
│ ├── services/
│ │ └── funnel-tracking.js # Funnel tracking service
│ ├── adapters/ # Tracking destination adapters
│ │ ├── woocommerce-tracks.js # WooCommerce Tracks integration
│ │ └── console-logger.js # Console output
│ ├── funnels/ # Funnel-specific configurations
│ │ └── onboarding.js # Onboarding funnel config & translations
│ ├── index.js # Main exports
│ └── init.js # Initialization system
├── data/ # Redux stores
│ ├── tracking/ # Dedicated tracking store
│ │ ├── actions.js # Field source tracking actions
│ │ ├── reducer.js # Field source state management
│ │ ├── selectors.js # Field source data access
│ │ └── index.js # Store initialization
│ ├── onboarding/ # Clean business logic store
│ │ ├── actions.js # Pure business actions
│ │ ├── reducer.js # Clean business logic
│ │ ├── selectors.js # Business data access
│ │ └── hooks.js # Enhanced hooks with tracking
│ ├── common/ # Clean business logic store
│ │ ├── actions.js # Pure business actions
│ │ ├── reducer.js # Clean business logic
│ │ ├── selectors.js # Business data access
│ │ └── hooks.js # Enhanced hooks with tracking
│ └── utils.js # Enhanced createHooksForStore
└── components/ # Enhanced to pass tracking sources
└── **/*.js # Form components updated with source attribution
```
## 1. Registry System (`registry.js`)
Manages funnel registration and coordinates multiple tracking concerns without conflicts.
### Store-to-Funnel Mapping
```javascript
// Registry maintains mapping of stores to multiple funnels
const trackingRegistry = {
funnels: {},
storeToFunnel: {}, // Store name -> array of funnel IDs
instances: {},
};
// Example mapping:
{
'wc/paypal/onboarding': ['ppcp_onboarding', 'settings_funnel'],
'wc/paypal/common': ['ppcp_onboarding', 'other_funnel'],
}
```
### Usage
```javascript
import { registerFunnel } from '../services/tracking/registry';
registerFunnel('ppcp_onboarding', onboardingConfig);
```
## 2. Subscription Manager (`subscription-manager.js`)
Creates **unified subscriptions** to WordPress data stores and routes changes to **multiple relevant funnels**.
### Single Subscription, Multiple Funnels
```javascript
class SubscriptionManager {
constructor() {
this.storeSubscriptions = {}; // ONE subscription per store
this.storeRegistrations = {}; // MULTIPLE funnel registrations per store
}
ensureStoreSubscription(storeName) {
if (this.storeSubscriptions[storeName]) {
return; // Skip if subscription already exists
}
// Create unified subscription for all funnels tracking this store
const unsubscribe = wp.data.subscribe(() => {
this.handleStoreChange(storeName);
});
}
}
```
### Benefits
- **One subscription per store** regardless of funnel count
- **Independent funnel logic** - each has its own rules and conditions
- **Isolated state** - each funnel tracks its own previous values
## 3. Tracking Store (`data/tracking/`)
Separate Redux store handles all field source information.
### State Structure
```javascript
// { storeName: { fieldName: { source, timestamp } } }
{
'wc/paypal/onboarding': {
'step': { source: 'user', timestamp: 1638360000000 },
'isCasualSeller': { source: 'user', timestamp: 1638360000000 }
},
'wc/paypal/common': {
'useSandbox': { source: 'system', timestamp: 1638360000000 }
}
}
```
## 4. Universal Hook System (`data/utils.js`)
`createHooksForStore` makes **any Redux store** tracking-compatible.
```javascript
// Works with ANY store
const { usePersistent, useTransient } = createHooksForStore('wc/paypal/any-store');
// In components
const [ field, setField ] = usePersistent('fieldName');
setField(newValue, 'user'); // Automatically tracked if configured
```
## 5. Source-Based Field Filtering
Field-level rules define which change sources trigger tracking events.
```javascript
// Configuration
fieldRules: {
step: { allowedSources: ['user', 'system'] }, // Track all changes
isCasualSeller: { allowedSources: ['user'] }, // Only user changes
}
// Usage
setIsCasualSeller(true, 'user'); // Tracked
setIsCasualSeller(false); // Filtered out (no source)
```
**Source Types:**
- `'user'` - Direct user interactions
- `'system'` - System-initiated changes
## 6. Funnel Configuration
Uses `FunnelConfigBuilder` pattern:
```javascript
// src/services/tracking/funnels/onboarding.js
export const config = FunnelConfigBuilder.createBasicFunnel(FUNNEL_ID, {
debug: false,
adapters: ['woocommerce-tracks'],
eventPrefix: 'ppcp_onboarding',
trackingCondition: {
store: 'wc/paypal/common',
selector: 'merchant',
field: 'isConnected',
expectedValue: false
}
})
.addEvents(EVENTS)
.addTranslations(TRANSLATIONS)
.addStore('wc/paypal/onboarding', [
createFieldTrackingConfig('step', 'persistent', {
rules: { allowedSources: ['user', 'system'] }
})
])
.build();
```
## 7. Initialization (`init.js`)
Required before store registration:
```javascript
import { registerFunnel } from './registry';
export function initializeTrackingFunnels() {
if (initialized) return;
registerFunnel(ONBOARDING_FUNNEL_ID, onboardingConfig);
initialized = true;
}
// Auto-initialize
initializeTrackingFunnels();
```
## 8. Store Registration
Stores register with funnels in their index files:
```javascript
// src/data/onboarding/index.js
import { addStoreToFunnel } from '../../services/tracking';
export const initStore = () => {
const store = createReduxStore(STORE_NAME, { reducer, actions, selectors });
register(store);
addStoreToFunnel(STORE_NAME, ONBOARDING_FUNNEL_ID);
return Boolean(wp.data.select(STORE_NAME));
};
```
## Adding Tracking to New Stores
### 1. Create Clean Business Store
```javascript
// actions.js
export const setPersistent = (prop, value) => ({
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [prop]: value },
});
// reducer.js
const reducer = createReducer(defaultTransient, defaultPersistent, {
[ACTION_TYPES.SET_PERSISTENT]: (state, payload) => changePersistent(state, payload),
});
```
### 2. Create Tracking-Enabled Hooks
```javascript
// hooks.js
import { createHooksForStore } from '../utils';
export const { usePersistent, useTransient } = createHooksForStore('wc/paypal/your-store');
```
### 3. Register Store
```javascript
// index.js
addStoreToFunnel(STORE_NAME, 'your-funnel-id');
```
### 4. Configure Funnel
```javascript
// funnels/your-funnel.js
export const config = FunnelConfigBuilder.createBasicFunnel('your-funnel', {
debug: false,
adapters: ['console'],
})
.addStore('wc/paypal/your-store', [
createFieldTrackingConfig('yourField', 'persistent', {
rules: { allowedSources: ['user'] }
})
])
.addTranslations({
yourField: (oldValue, newValue, metadata, trackingService) => {
trackingService.sendToAdapters('your_event_name', {
new_value: newValue,
old_value: oldValue
});
}
})
.build();
```
## Multi-Funnel Example
Multiple funnels tracking the same store:
```javascript
// Both register interest in same store
addStoreToFunnel('wc/paypal/onboarding', 'ppcp_onboarding');
addStoreToFunnel('wc/paypal/onboarding', 'settings_funnel');
// Results in ONE subscription, TWO registrations with different rules:
storeRegistrations = {
'wc/paypal/onboarding': [
{
funnelId: 'ppcp_onboarding',
fieldRules: { step: {allowedSources: ['user', 'system']} },
trackingCondition: { field: 'isConnected', expectedValue: false },
previousValues: {} // Separate per funnel
},
{
funnelId: 'settings_funnel',
fieldRules: { step: {allowedSources: ['user']} },
trackingCondition: { field: 'isConnected', expectedValue: true },
previousValues: {} // Separate per funnel
}
]
}
```
## Debugging
### Enable Debug Mode
```javascript
export const config = FunnelConfigBuilder.createBasicFunnel('funnel', {
debug: true,
});
```
### Inspect Tracking Store
```javascript
const trackingStore = wp.data.select('wc/paypal/tracking');
console.log('All sources:', trackingStore.getAllFieldSources());
console.log('Store sources:', trackingStore.getStoreFieldSources('wc/paypal/onboarding'));
```
### Check Registry Status
```javascript
import { getTrackingStatus, getMultiFunnelStores } from '../services/tracking';
console.log('Status:', getTrackingStatus());
console.log('Multi-funnel stores:', getMultiFunnelStores());
```
## Event Schema
Events follow pattern: `ppcp_{funnel}_{action}_{object}`
Examples:
- `ppcp_onboarding_account_type_select`
- `ppcp_onboarding_step_forward`
- `ppcp_settings_payment_method_toggle`

View file

@ -0,0 +1,67 @@
/**
* Utilities: Essential tracking helper functions.
*
* Provides helper functions for the tracking system.
* Includes field validation, metadata creation, and store utilities.
*
* @file
*/
/**
* 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,472 @@
/**
* 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 ];
} ),
...( options.rules && { rules: 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 };
},
};