mirror of
https://github.com/woocommerce/woocommerce-paypal-payments.git
synced 2025-09-05 08:59:14 +08:00
✨ Add Tracks support for the Onboarding flow
This commit is contained in:
parent
b5c9a6f5f4
commit
e7d9d6f400
36 changed files with 3618 additions and 134 deletions
|
@ -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 ) => {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Button } from '@wordpress/components';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import { useEffect, useCallback } from '@wordpress/element';
|
||||
import classNames from 'classnames';
|
||||
import { OpenSignup } from '../../../ReusableComponents/Icons';
|
||||
import { useHandleOnboardingButton } from '../../../../hooks/useHandleConnections';
|
||||
import { OnboardingHooks } from '../../../../data/onboarding/hooks';
|
||||
import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
|
||||
|
||||
/**
|
||||
|
@ -10,12 +11,13 @@ import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
|
|||
* placeholder button looks identical to the working button, but has no href, target, or
|
||||
* custom connection attributes.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.className
|
||||
* @param {string} props.variant
|
||||
* @param {boolean} props.showIcon
|
||||
* @param {?string} props.href
|
||||
* @param {Element} props.children
|
||||
* @param {Object} props
|
||||
* @param {string} props.className
|
||||
* @param {string} props.variant
|
||||
* @param {boolean} props.showIcon
|
||||
* @param {?string} props.href
|
||||
* @param {Element} props.children
|
||||
* @param {Function} props.onClick
|
||||
*/
|
||||
const ButtonOrPlaceholder = ( {
|
||||
className,
|
||||
|
@ -23,11 +25,13 @@ const ButtonOrPlaceholder = ( {
|
|||
showIcon,
|
||||
href,
|
||||
children,
|
||||
onClick,
|
||||
} ) => {
|
||||
const buttonProps = {
|
||||
className,
|
||||
variant,
|
||||
icon: showIcon ? OpenSignup : null,
|
||||
onClick,
|
||||
};
|
||||
|
||||
if ( href ) {
|
||||
|
@ -52,12 +56,28 @@ const ConnectionButton = ( {
|
|||
setCompleteHandler,
|
||||
removeCompleteHandler,
|
||||
} = useHandleOnboardingButton( isSandbox );
|
||||
|
||||
const { connectionButtonClicked, setConnectionButtonClicked } =
|
||||
OnboardingHooks.useConnectionButton();
|
||||
|
||||
const buttonClassName = classNames( 'ppcp-r-connection-button', className, {
|
||||
'ppcp--mode-sandbox': isSandbox,
|
||||
'ppcp--mode-live': ! isSandbox,
|
||||
'ppcp--button-clicked': connectionButtonClicked,
|
||||
} );
|
||||
const environment = isSandbox ? 'sandbox' : 'production';
|
||||
|
||||
const handleButtonClick = useCallback( () => {
|
||||
setConnectionButtonClicked( true );
|
||||
}, [ setConnectionButtonClicked ] );
|
||||
|
||||
// Reset button clicked state when onboardingUrl becomes available.
|
||||
useEffect( () => {
|
||||
if ( onboardingUrl && connectionButtonClicked ) {
|
||||
setConnectionButtonClicked( false );
|
||||
}
|
||||
}, [ onboardingUrl, connectionButtonClicked, setConnectionButtonClicked ] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( scriptLoaded && onboardingUrl ) {
|
||||
window.PAYPAL.apps.Signup.render();
|
||||
|
@ -82,6 +102,7 @@ const ConnectionButton = ( {
|
|||
variant={ variant }
|
||||
showIcon={ showIcon }
|
||||
href={ onboardingUrl }
|
||||
onClick={ handleButtonClick }
|
||||
>
|
||||
<span className="button-title">{ title }</span>
|
||||
</ButtonOrPlaceholder>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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={ __(
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 }
|
||||
/>
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ const StepProducts = () => {
|
|||
};
|
||||
|
||||
initChoices();
|
||||
}, [ canUseSubscriptions, optionState, products, setProducts ] );
|
||||
}, [ canUseSubscriptions, isCasualSeller ] );
|
||||
|
||||
const handleChange = ( key, checked ) => {
|
||||
const getNewValue = () => {
|
||||
|
@ -48,7 +48,7 @@ const StepProducts = () => {
|
|||
return products.filter( ( val ) => val !== key );
|
||||
};
|
||||
|
||||
setProducts( getNewValue() );
|
||||
setProducts( getNewValue(), 'user' );
|
||||
};
|
||||
const productChoicesFull = [
|
||||
{
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -18,4 +18,7 @@ export default {
|
|||
// Activity management (advanced solution that replaces the isBusy state).
|
||||
START_ACTIVITY: 'ppcp/common/START_ACTIVITY',
|
||||
STOP_ACTIVITY: 'ppcp/common/STOP_ACTIVITY',
|
||||
|
||||
// Tracking.
|
||||
CLEAR_FIELD_SOURCE: 'ppcp/common/CLEAR_FIELD_SOURCE',
|
||||
};
|
||||
|
|
|
@ -36,26 +36,34 @@ export const hydrate = ( payload ) => ( {
|
|||
/**
|
||||
* Generic transient-data updater.
|
||||
*
|
||||
* @param {string} prop Name of the property to update.
|
||||
* @param {any} value The new value of the property.
|
||||
* @param {string} prop Name of the property to update.
|
||||
* @param {any} value The new value of the property.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setTransient = ( prop, value ) => ( {
|
||||
export const setTransient = ( prop, value, source = '' ) => ( {
|
||||
type: ACTION_TYPES.SET_TRANSIENT,
|
||||
payload: { [ prop ]: value },
|
||||
source,
|
||||
fieldName: prop,
|
||||
} );
|
||||
|
||||
/**
|
||||
* Generic persistent-data updater.
|
||||
*
|
||||
* @param {string} prop Name of the property to update.
|
||||
* @param {any} value The new value of the property.
|
||||
* @param {string} prop Name of the property to update.
|
||||
* @param {any} value The new value of the property.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setPersistent = ( prop, value ) => ( {
|
||||
type: ACTION_TYPES.SET_PERSISTENT,
|
||||
payload: { [ prop ]: value },
|
||||
} );
|
||||
export const setPersistent = ( prop, value, source = '' ) => {
|
||||
return {
|
||||
type: ACTION_TYPES.SET_PERSISTENT,
|
||||
payload: { [ prop ]: value },
|
||||
source,
|
||||
fieldName: prop,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Transient. Marks the onboarding details as "ready", i.e., fully initialized.
|
||||
|
@ -78,19 +86,22 @@ export const setActiveModal = ( activeModal ) =>
|
|||
* Persistent. Sets the sandbox mode on or off.
|
||||
*
|
||||
* @param {boolean} useSandbox
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setSandboxMode = ( useSandbox ) =>
|
||||
setPersistent( 'useSandbox', useSandbox );
|
||||
export const setSandboxMode = ( useSandbox, source ) => {
|
||||
return setPersistent( 'useSandbox', useSandbox, source );
|
||||
};
|
||||
|
||||
/**
|
||||
* Persistent. Toggles the "Manual Connection" mode on or off.
|
||||
*
|
||||
* @param {boolean} useManualConnection
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setManualConnectionMode = ( useManualConnection ) =>
|
||||
setPersistent( 'useManualConnection', useManualConnection );
|
||||
export const setManualConnectionMode = ( useManualConnection, source ) =>
|
||||
setPersistent( 'useManualConnection', useManualConnection, source );
|
||||
|
||||
/**
|
||||
* Persistent. Changes the "webhooks" value.
|
||||
|
@ -152,3 +163,14 @@ export const stopActivity = ( id ) => ( {
|
|||
type: ACTION_TYPES.STOP_ACTIVITY,
|
||||
payload: { id },
|
||||
} );
|
||||
|
||||
/**
|
||||
* Action creator to clear field source tracking.
|
||||
*
|
||||
* @param {string} fieldName - Name of the field to clear source for.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const clearFieldSource = ( fieldName ) => ( {
|
||||
type: ACTION_TYPES.CLEAR_FIELD_SOURCE,
|
||||
payload: { fieldName },
|
||||
} );
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 ) );
|
||||
};
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ const defaultTransient = Object.freeze( {
|
|||
activities: new Map(),
|
||||
activeModal: '',
|
||||
activeHighlight: '',
|
||||
fieldSources: Object.freeze( {} ),
|
||||
|
||||
// Read only values, provided by the server via hydrate.
|
||||
merchant: Object.freeze( {
|
||||
|
@ -73,21 +74,76 @@ const defaultPersistent = Object.freeze( {
|
|||
useManualConnection: false,
|
||||
} );
|
||||
|
||||
// Reducer logic.
|
||||
const updateFieldSources = ( currentSources, fieldName, source ) => {
|
||||
if ( ! source ) {
|
||||
return currentSources;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentSources,
|
||||
[ fieldName ]: {
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const clearFieldSource = ( currentSources, fieldName ) => {
|
||||
const newSources = { ...currentSources };
|
||||
delete newSources[ fieldName ];
|
||||
return newSources;
|
||||
};
|
||||
|
||||
// Reducer logic.
|
||||
const [ changeTransient, changePersistent ] = createReducerSetters(
|
||||
defaultTransient,
|
||||
defaultPersistent
|
||||
);
|
||||
|
||||
const commonReducer = createReducer( defaultTransient, defaultPersistent, {
|
||||
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, action ) =>
|
||||
changeTransient( state, action ),
|
||||
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload, action ) => {
|
||||
const newState = changeTransient( state, payload );
|
||||
|
||||
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, action ) =>
|
||||
changePersistent( state, action ),
|
||||
if ( action && action.source ) {
|
||||
const fieldName = Object.keys( payload )[ 0 ];
|
||||
|
||||
[ ACTION_TYPES.RESET ]: ( state ) => {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
fieldName,
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload, action ) => {
|
||||
const newState = changePersistent( state, payload );
|
||||
|
||||
if ( action && action.source ) {
|
||||
const fieldName = Object.keys( payload )[ 0 ];
|
||||
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
fieldName,
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.CLEAR_FIELD_SOURCE ]: ( state, payload ) => {
|
||||
const newState = { ...state };
|
||||
newState.fieldSources = clearFieldSource(
|
||||
newState.fieldSources,
|
||||
payload.fieldName
|
||||
);
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.RESET ]: ( state, payload, action ) => {
|
||||
const cleanState = changeTransient(
|
||||
changePersistent( state, defaultPersistent ),
|
||||
defaultTransient
|
||||
|
@ -99,37 +155,109 @@ const commonReducer = createReducer( defaultTransient, defaultPersistent, {
|
|||
cleanState.features = { ...state.features };
|
||||
cleanState.isReady = true;
|
||||
|
||||
if ( action && action.source ) {
|
||||
cleanState.fieldSources = updateFieldSources(
|
||||
cleanState.fieldSources,
|
||||
'reset',
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return cleanState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.START_ACTIVITY ]: ( state, payload ) => {
|
||||
return changeTransient( state, {
|
||||
[ ACTION_TYPES.START_ACTIVITY ]: ( state, payload, action ) => {
|
||||
const newState = changeTransient( state, {
|
||||
activities: new Map( state.activities ).set(
|
||||
payload.id,
|
||||
payload.description
|
||||
),
|
||||
} );
|
||||
|
||||
if ( action && action.source ) {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
'activity_' + payload.id,
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.STOP_ACTIVITY ]: ( state, payload ) => {
|
||||
[ ACTION_TYPES.STOP_ACTIVITY ]: ( state, payload, action ) => {
|
||||
const newActivities = new Map( state.activities );
|
||||
newActivities.delete( payload.id );
|
||||
return changeTransient( state, { activities: newActivities } );
|
||||
const newState = changeTransient( state, {
|
||||
activities: newActivities,
|
||||
} );
|
||||
|
||||
if ( action && action.source ) {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
'activity_stop_' + payload.id,
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
// Instantly reset the merchant data and features before refreshing the details.
|
||||
[ ACTION_TYPES.RESET_MERCHANT ]: ( state ) => ( {
|
||||
...state,
|
||||
merchant: Object.freeze( { ...defaultTransient.merchant } ),
|
||||
features: Object.freeze( { ...defaultTransient.features } ),
|
||||
} ),
|
||||
[ ACTION_TYPES.RESET_MERCHANT ]: ( state, payload, action ) => {
|
||||
const newState = {
|
||||
...state,
|
||||
merchant: Object.freeze( { ...defaultTransient.merchant } ),
|
||||
features: Object.freeze( { ...defaultTransient.features } ),
|
||||
};
|
||||
|
||||
[ ACTION_TYPES.SET_MERCHANT ]: ( state, payload ) => {
|
||||
return changePersistent( state, { merchant: payload.merchant } );
|
||||
if ( action && action.source ) {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
'reset_merchant',
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
|
||||
const newState = changePersistent( state, payload.data );
|
||||
[ ACTION_TYPES.SET_MERCHANT ]: ( state, payload, action ) => {
|
||||
const newState = changePersistent( state, {
|
||||
merchant: payload.merchant,
|
||||
} );
|
||||
|
||||
if ( action && action.source ) {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
'merchant',
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.HYDRATE ]: ( state, payload, action ) => {
|
||||
let newState = { ...state };
|
||||
|
||||
if ( action && action.source && payload.data ) {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
'hydrate',
|
||||
action.source
|
||||
);
|
||||
|
||||
Object.keys( payload.data ).forEach( ( fieldName ) => {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
fieldName,
|
||||
action.source
|
||||
);
|
||||
} );
|
||||
}
|
||||
|
||||
newState = changePersistent( newState, payload.data );
|
||||
|
||||
// Populate read-only properties.
|
||||
[ 'wooSettings', 'merchant', 'features', 'webhooks' ].forEach(
|
||||
|
|
|
@ -32,6 +32,32 @@ export const getActivityList = ( state ) => {
|
|||
return Object.fromEntries( activities );
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the source information for a specific field.
|
||||
* @param {Object} state - Store state
|
||||
* @param {string} fieldName
|
||||
*/
|
||||
export const getFieldSource = ( state, fieldName ) => {
|
||||
const fieldSources = state?.fieldSources || {};
|
||||
const fieldSource = fieldSources[ fieldName ];
|
||||
|
||||
if ( ! fieldSource ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fieldSource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all field sources for debugging.
|
||||
* @param {Object} state - Store state
|
||||
* @return {Object} All field source information
|
||||
*/
|
||||
export const getAllFieldSources = ( state ) => {
|
||||
const currentState = getState( state );
|
||||
return currentState.fieldSources || {};
|
||||
};
|
||||
|
||||
export const merchant = ( state ) => {
|
||||
return getState( state ).merchant || EMPTY_OBJ;
|
||||
};
|
||||
|
|
|
@ -8,6 +8,9 @@ import * as Todos from './todos';
|
|||
import * as PayLaterMessaging from './pay-later-messaging';
|
||||
import * as Features from './features';
|
||||
|
||||
// Initialize tracking funnels before any store initialization.
|
||||
import '../services/tracking/init';
|
||||
|
||||
const stores = [
|
||||
Onboarding,
|
||||
Common,
|
||||
|
|
|
@ -13,7 +13,10 @@ export default {
|
|||
RESET: 'ppcp/onboarding/RESET',
|
||||
HYDRATE: 'ppcp/onboarding/HYDRATE',
|
||||
|
||||
// Gateway sync flag
|
||||
// Gateway sync flag.
|
||||
SYNC_GATEWAYS: 'ppcp/onboarding/SYNC_GATEWAYS',
|
||||
REFRESH_GATEWAYS: 'ppcp/onboarding/REFRESH_GATEWAYS',
|
||||
|
||||
// Tracking.
|
||||
CLEAR_FIELD_SOURCE: 'ppcp/onboarding/CLEAR_FIELD_SOURCE',
|
||||
};
|
||||
|
|
|
@ -16,14 +16,19 @@ import { REST_PERSIST_PATH } from './constants';
|
|||
* @typedef {Object} Action An action object that is handled by a reducer or control.
|
||||
* @property {string} type - The action type.
|
||||
* @property {Object?} payload - Optional payload for the action.
|
||||
* @property {string?} source - Optional source context for tracking.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Special. Resets all values in the onboarding store to initial defaults.
|
||||
*
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const reset = () => ( { type: ACTION_TYPES.RESET } );
|
||||
export const reset = ( source ) => ( {
|
||||
type: ACTION_TYPES.RESET,
|
||||
source,
|
||||
} );
|
||||
|
||||
/**
|
||||
* Persistent. Set the full onboarding details, usually during app initialization.
|
||||
|
@ -34,30 +39,37 @@ export const reset = () => ( { type: ACTION_TYPES.RESET } );
|
|||
export const hydrate = ( payload ) => ( {
|
||||
type: ACTION_TYPES.HYDRATE,
|
||||
payload,
|
||||
source: 'system',
|
||||
} );
|
||||
|
||||
/**
|
||||
* Generic transient-data updater.
|
||||
*
|
||||
* @param {string} prop Name of the property to update.
|
||||
* @param {any} value The new value of the property.
|
||||
* @param {string} prop Name of the property to update.
|
||||
* @param {any} value The new value of the property.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setTransient = ( prop, value ) => ( {
|
||||
export const setTransient = ( prop, value, source = '' ) => ( {
|
||||
type: ACTION_TYPES.SET_TRANSIENT,
|
||||
payload: { [ prop ]: value },
|
||||
source,
|
||||
fieldName: prop,
|
||||
} );
|
||||
|
||||
/**
|
||||
* Generic persistent-data updater.
|
||||
*
|
||||
* @param {string} prop Name of the property to update.
|
||||
* @param {any} value The new value of the property.
|
||||
* @param {string} prop Name of the property to update.
|
||||
* @param {any} value The new value of the property.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setPersistent = ( prop, value ) => ( {
|
||||
export const setPersistent = ( prop, value, source ) => ( {
|
||||
type: ACTION_TYPES.SET_PERSISTENT,
|
||||
payload: { [ prop ]: value },
|
||||
source,
|
||||
fieldName: prop,
|
||||
} );
|
||||
|
||||
/**
|
||||
|
@ -66,7 +78,8 @@ export const setPersistent = ( prop, value ) => ( {
|
|||
* @param {boolean} isReady
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady );
|
||||
export const setIsReady = ( isReady ) =>
|
||||
setTransient( 'isReady', isReady, 'system' );
|
||||
|
||||
/**
|
||||
* Thunk action creator. Triggers the persistence of onboarding data to the server.
|
||||
|
@ -104,39 +117,139 @@ export function refresh() {
|
|||
/**
|
||||
* Persistent. Updates the gateway synced status.
|
||||
*
|
||||
* @param {boolean} synced The sync status to set
|
||||
* @param {boolean} synced The sync status to set.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const updateGatewaysSynced = ( synced = true ) =>
|
||||
setPersistent( 'gatewaysSynced', synced );
|
||||
export const updateGatewaysSynced = ( synced = true, source ) =>
|
||||
setPersistent( 'gatewaysSynced', synced, source );
|
||||
|
||||
/**
|
||||
* Persistent. Updates the gateway refreshed status.
|
||||
*
|
||||
* @param {boolean} refreshed The refreshed status to set
|
||||
* @param {boolean} refreshed The refreshed status to set.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const updateGatewaysRefreshed = ( refreshed = true ) =>
|
||||
setPersistent( 'gatewaysRefreshed', refreshed );
|
||||
export const updateGatewaysRefreshed = ( refreshed = true, source ) =>
|
||||
setPersistent( 'gatewaysRefreshed', refreshed, source );
|
||||
|
||||
/**
|
||||
* Action creator to sync payment gateways.
|
||||
* This will both update the state and persist it.
|
||||
*
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Function} The thunk function.
|
||||
*/
|
||||
export function syncGateways() {
|
||||
export function syncGateways( source ) {
|
||||
return async ( { dispatch } ) => {
|
||||
dispatch( setPersistent( 'gatewaysSynced', true ) );
|
||||
dispatch( setPersistent( 'gatewaysSynced', true, source ) );
|
||||
await dispatch.persist();
|
||||
return { success: true };
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshGateways() {
|
||||
/**
|
||||
* Action creator to refresh payment gateways.
|
||||
*
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Function} The thunk function.
|
||||
*/
|
||||
export function refreshGateways( source ) {
|
||||
return async ( { dispatch } ) => {
|
||||
dispatch( setPersistent( 'gatewaysRefreshed', true ) );
|
||||
dispatch( setPersistent( 'gatewaysRefreshed', true, source ) );
|
||||
await dispatch.persist();
|
||||
return { success: true };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action creator to clear field source tracking.
|
||||
*
|
||||
* @param {string} fieldName - Name of the field to clear source for.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const clearFieldSource = ( fieldName ) => ( {
|
||||
type: ACTION_TYPES.CLEAR_FIELD_SOURCE,
|
||||
payload: { fieldName },
|
||||
} );
|
||||
|
||||
/**
|
||||
* Transient. Updates the connection button clicked status.
|
||||
*
|
||||
* @param {boolean} clicked Whether the button was clicked.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setConnectionButtonClicked = ( clicked = true, source = 'user' ) =>
|
||||
setTransient( 'connectionButtonClicked', clicked, source );
|
||||
|
||||
/**
|
||||
* Persistent. Updates the current step in the onboarding flow.
|
||||
*
|
||||
* @param {number} step The step number to set.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setStep = ( step, source ) =>
|
||||
setPersistent( 'step', step, source );
|
||||
|
||||
/**
|
||||
* Persistent. Updates the completed status of the onboarding.
|
||||
*
|
||||
* @param {boolean} completed Whether onboarding is completed.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setCompleted = ( completed, source ) =>
|
||||
setPersistent( 'completed', completed, source );
|
||||
|
||||
/**
|
||||
* Persistent. Updates the casual seller status.
|
||||
*
|
||||
* @param {boolean} isCasualSeller Whether the user is a casual seller.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setIsCasualSeller = ( isCasualSeller, source ) =>
|
||||
setPersistent( 'isCasualSeller', isCasualSeller, source );
|
||||
|
||||
/**
|
||||
* Persistent. Updates the optional payment methods setting.
|
||||
*
|
||||
* @param {boolean} enabled Whether optional payment methods are enabled.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setOptionalPaymentMethods = ( enabled, source ) =>
|
||||
setPersistent( 'areOptionalPaymentMethodsEnabled', enabled, source );
|
||||
|
||||
/**
|
||||
* Persistent. Updates the selected products.
|
||||
*
|
||||
* @param {Array} products Array of selected product types.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setProducts = ( products, source ) =>
|
||||
setPersistent( 'products', products, source );
|
||||
|
||||
/**
|
||||
* Transient. Updates the manual client ID.
|
||||
*
|
||||
* @param {string} clientId The manual client ID.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setManualClientId = ( clientId, source ) =>
|
||||
setTransient( 'manualClientId', clientId, source );
|
||||
|
||||
/**
|
||||
* Transient. Updates the manual client secret.
|
||||
*
|
||||
* @param {string} clientSecret The manual client secret.
|
||||
* @param {string} source Optional source context for tracking.
|
||||
* @return {Action} The action.
|
||||
*/
|
||||
export const setManualClientSecret = ( clientSecret, source ) =>
|
||||
setTransient( 'manualClientSecret', clientSecret, source );
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 ) );
|
||||
};
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@ const defaultTransient = Object.freeze( {
|
|||
isReady: false,
|
||||
manualClientId: '',
|
||||
manualClientSecret: '',
|
||||
connectionButtonClicked: false,
|
||||
fieldSources: Object.freeze( {} ),
|
||||
|
||||
// Read only values, provided by the server.
|
||||
flags: Object.freeze( {
|
||||
|
@ -39,6 +41,26 @@ const defaultPersistent = Object.freeze( {
|
|||
gatewaysRefreshed: false,
|
||||
} );
|
||||
|
||||
const updateFieldSources = ( currentSources, fieldName, source ) => {
|
||||
if ( ! source ) {
|
||||
return currentSources;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentSources,
|
||||
[ fieldName ]: {
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const clearFieldSource = ( currentSources, fieldName ) => {
|
||||
const newSources = { ...currentSources };
|
||||
delete newSources[ fieldName ];
|
||||
return newSources;
|
||||
};
|
||||
|
||||
// Reducer logic.
|
||||
|
||||
const [ changeTransient, changePersistent ] = createReducerSetters(
|
||||
|
@ -47,13 +69,48 @@ const [ changeTransient, changePersistent ] = createReducerSetters(
|
|||
);
|
||||
|
||||
const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
|
||||
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) =>
|
||||
changeTransient( state, payload ),
|
||||
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload, action ) => {
|
||||
const newState = changeTransient( state, payload );
|
||||
|
||||
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) =>
|
||||
changePersistent( state, payload ),
|
||||
if ( action && action.source ) {
|
||||
const fieldName = Object.keys( payload )[ 0 ];
|
||||
|
||||
[ ACTION_TYPES.RESET ]: ( state ) => {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
fieldName,
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload, action ) => {
|
||||
const newState = changePersistent( state, payload );
|
||||
|
||||
if ( action && action.source ) {
|
||||
const fieldName = Object.keys( payload )[ 0 ];
|
||||
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
fieldName,
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.CLEAR_FIELD_SOURCE ]: ( state, payload ) => {
|
||||
const newState = { ...state };
|
||||
newState.fieldSources = clearFieldSource(
|
||||
newState.fieldSources,
|
||||
payload.fieldName
|
||||
);
|
||||
|
||||
return newState;
|
||||
},
|
||||
[ ACTION_TYPES.RESET ]: ( state, payload, action ) => {
|
||||
const cleanState = changeTransient(
|
||||
changePersistent( state, defaultPersistent ),
|
||||
defaultTransient
|
||||
|
@ -63,11 +120,37 @@ const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
|
|||
cleanState.flags = { ...state.flags };
|
||||
cleanState.isReady = true;
|
||||
|
||||
if ( action && action.source ) {
|
||||
cleanState.fieldSources = updateFieldSources(
|
||||
cleanState.fieldSources,
|
||||
'reset',
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return cleanState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
|
||||
const newState = changePersistent( state, payload.data );
|
||||
[ ACTION_TYPES.HYDRATE ]: ( state, payload, action ) => {
|
||||
let newState = { ...state };
|
||||
|
||||
if ( action && action.source && payload.data ) {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
'hydrate',
|
||||
action.source
|
||||
);
|
||||
|
||||
Object.keys( payload.data ).forEach( ( fieldName ) => {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
fieldName,
|
||||
action.source
|
||||
);
|
||||
} );
|
||||
}
|
||||
|
||||
newState = changePersistent( newState, payload.data );
|
||||
|
||||
// Flags are not updated by `changePersistent()`.
|
||||
if ( payload.flags ) {
|
||||
|
@ -80,12 +163,34 @@ const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
|
|||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.SYNC_GATEWAYS ]: ( state ) => {
|
||||
return changePersistent( state, { gatewaysSynced: true } );
|
||||
[ ACTION_TYPES.SYNC_GATEWAYS ]: ( state, payload, action ) => {
|
||||
const newState = changePersistent( state, { gatewaysSynced: true } );
|
||||
|
||||
if ( action && action.source ) {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
'gatewaysSynced',
|
||||
action.source
|
||||
);
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
|
||||
[ ACTION_TYPES.REFRESH_GATEWAYS ]: ( state ) => {
|
||||
return changePersistent( state, { gatewaysRefreshed: true } );
|
||||
[ ACTION_TYPES.REFRESH_GATEWAYS ]: ( state, payload, action ) => {
|
||||
const newState = changePersistent( state, { gatewaysRefreshed: true } );
|
||||
|
||||
if ( action ) {
|
||||
if ( action.source ) {
|
||||
newState.fieldSources = updateFieldSources(
|
||||
newState.fieldSources,
|
||||
'gatewaysRefreshed',
|
||||
action.source
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return newState;
|
||||
},
|
||||
} );
|
||||
|
||||
|
|
|
@ -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' )
|
||||
|
|
|
@ -18,7 +18,7 @@ export const persistentData = ( state ) => {
|
|||
};
|
||||
|
||||
export const transientData = ( state ) => {
|
||||
const { data, flags, ...transientState } = getState( state );
|
||||
const { data, flags, fieldSources, ...transientState } = getState( state );
|
||||
return transientState || EMPTY_OBJ;
|
||||
};
|
||||
|
||||
|
@ -26,6 +26,43 @@ export const flags = ( state ) => {
|
|||
return getState( state ).flags || EMPTY_OBJ;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the source information for a specific field.
|
||||
* @param {Object} state - Store state.
|
||||
* @param {string} fieldName
|
||||
*/
|
||||
export const getFieldSource = ( state, fieldName ) => {
|
||||
const fieldSources = state?.fieldSources || {};
|
||||
const fieldSource = fieldSources[ fieldName ];
|
||||
|
||||
if ( ! fieldSource ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fieldSource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all field sources for debugging.
|
||||
* @param {Object} state - Store state.
|
||||
* @return {Object} All field source information.
|
||||
*/
|
||||
export const getAllFieldSources = ( state ) => {
|
||||
const currentState = getState( state );
|
||||
return currentState.fieldSources || {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the connection button clicked status.
|
||||
*
|
||||
* @param {Object} state - Store state.
|
||||
* @return {boolean} Whether the connection button has been clicked.
|
||||
*/
|
||||
export const getConnectionButtonClicked = ( state ) => {
|
||||
const transient = transientData( state );
|
||||
return transient.connectionButtonClicked || false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns details about products and capabilities to use for the production login link in
|
||||
* the last onboarding step.
|
||||
|
|
|
@ -74,7 +74,11 @@ export const createReducer = (
|
|||
|
||||
return function reducer( state = initialState, action ) {
|
||||
if ( Object.hasOwnProperty.call( handlers, action.type ) ) {
|
||||
return handlers[ action.type ]( state, action.payload ?? {} );
|
||||
return handlers[ action.type ](
|
||||
state,
|
||||
action.payload ?? {},
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
|
@ -121,13 +125,13 @@ export const createHooksForStore = ( storeName ) => {
|
|||
const actions = useDispatch( storeName );
|
||||
|
||||
const setValue = useCallback(
|
||||
( newValue ) => {
|
||||
( newValue, source ) => {
|
||||
if ( ! actions?.[ dispatcher ] ) {
|
||||
throw new Error(
|
||||
`Please create the action "${ dispatcher }" for store "${ storeName }"`
|
||||
);
|
||||
}
|
||||
actions[ dispatcher ]( key, newValue );
|
||||
actions[ dispatcher ]( key, newValue, source );
|
||||
},
|
||||
[ actions, key ]
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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: [],
|
||||
},
|
||||
];
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 );
|
||||
}
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* Onboarding Funnel: Complete tracking configuration for onboarding flow.
|
||||
*
|
||||
* Defines events, translations, and field configurations for PayPal onboarding tracking.
|
||||
* Includes step progression tracking, user selections, and completion events.
|
||||
*
|
||||
* @file
|
||||
*/
|
||||
|
||||
import {
|
||||
FunnelConfigBuilder,
|
||||
createFieldTrackingConfig,
|
||||
createSystemFieldTrackingConfig,
|
||||
createTransientFieldTrackingConfig,
|
||||
createBooleanFieldTrackingConfig,
|
||||
createArrayFieldTrackingConfig,
|
||||
} from '../utils/field-config-helpers';
|
||||
|
||||
export const FUNNEL_ID = 'ppcp_onboarding';
|
||||
|
||||
// Event names specific to this funnel.
|
||||
export const EVENTS = {
|
||||
welcome_view: 'ppcp_onboarding_welcome_view',
|
||||
account_type_view: 'ppcp_onboarding_account_type_view',
|
||||
products_view: 'ppcp_onboarding_products_view',
|
||||
payment_options_view: 'ppcp_onboarding_payment_options_view',
|
||||
complete_view: 'ppcp_onboarding_complete_view',
|
||||
account_type_select: 'ppcp_onboarding_account_type_business_type_select',
|
||||
products_select: 'ppcp_onboarding_products_products_select',
|
||||
payment_options_select:
|
||||
'ppcp_onboarding_payment_options_payment_method_select',
|
||||
sandbox_mode_select: 'ppcp_onboarding_sandbox_mode_select',
|
||||
manual_connection_select: 'ppcp_onboarding_manual_connection_select',
|
||||
complete_connect_click: 'ppcp_onboarding_complete_connect_click',
|
||||
};
|
||||
|
||||
// Step metadata.
|
||||
export const STEP_INFO = {
|
||||
0: { name: 'welcome', viewEvent: EVENTS.welcome_view },
|
||||
1: { name: 'account_type', viewEvent: EVENTS.account_type_view },
|
||||
2: { name: 'products', viewEvent: EVENTS.products_view },
|
||||
3: { name: 'payment_options', viewEvent: EVENTS.payment_options_view },
|
||||
4: { name: 'complete', viewEvent: EVENTS.complete_view },
|
||||
};
|
||||
|
||||
// Translation functions specific to this funnel.
|
||||
export const TRANSLATIONS = {
|
||||
step: ( oldStep, newStep, metadata, trackingService ) => {
|
||||
const stepInfo = STEP_INFO[ newStep ];
|
||||
if ( ! stepInfo ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
step_number: newStep,
|
||||
step_name: stepInfo.name,
|
||||
...trackingService.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
trackingService.sendToAdapters( stepInfo.viewEvent, eventData );
|
||||
},
|
||||
|
||||
isCasualSeller: ( oldValue, newValue, metadata, trackingService ) => {
|
||||
if ( newValue === null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountType = newValue === true ? 'personal' : 'business';
|
||||
const eventData = {
|
||||
selected_value: accountType,
|
||||
step_number: metadata.currentStep,
|
||||
step_name: metadata.stepName,
|
||||
...trackingService.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
trackingService.sendToAdapters( EVENTS.account_type_select, eventData );
|
||||
},
|
||||
|
||||
products: ( oldValue, newValue, metadata, trackingService ) => {
|
||||
if ( ! Array.isArray( newValue ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
selected_products: newValue.join( ',' ),
|
||||
products_count: newValue.length,
|
||||
previous_products: Array.isArray( oldValue )
|
||||
? oldValue.join( ',' )
|
||||
: 'none',
|
||||
step_number: metadata.currentStep,
|
||||
step_name: metadata.stepName,
|
||||
...trackingService.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
trackingService.sendToAdapters( EVENTS.products_select, eventData );
|
||||
},
|
||||
|
||||
areOptionalPaymentMethodsEnabled: (
|
||||
oldValue,
|
||||
newValue,
|
||||
metadata,
|
||||
trackingService
|
||||
) => {
|
||||
if ( newValue === null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentOption = newValue ? 'expanded' : 'no_cards';
|
||||
const eventData = {
|
||||
selected_value: paymentOption,
|
||||
step_number: metadata.currentStep,
|
||||
step_name: metadata.stepName,
|
||||
...trackingService.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
trackingService.sendToAdapters(
|
||||
EVENTS.payment_options_select,
|
||||
eventData
|
||||
);
|
||||
},
|
||||
|
||||
completed: ( oldValue, newValue, metadata, trackingService ) => {
|
||||
if ( newValue === true ) {
|
||||
const eventData = {
|
||||
step_number: metadata.currentStep,
|
||||
step_name: metadata.stepName,
|
||||
total_duration_ms:
|
||||
Date.now() - trackingService.sessionStartTime,
|
||||
final_account_type: metadata?.isCasualSeller
|
||||
? 'personal'
|
||||
: 'business',
|
||||
final_products: Array.isArray( metadata?.products )
|
||||
? metadata.products.join( ',' )
|
||||
: '',
|
||||
final_payment_options:
|
||||
metadata?.areOptionalPaymentMethodsEnabled
|
||||
? 'expanded'
|
||||
: 'no_cards',
|
||||
final_sandbox_mode: metadata?.useSandbox
|
||||
? 'enabled'
|
||||
: 'disabled',
|
||||
...trackingService.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
trackingService.sendToAdapters(
|
||||
EVENTS.complete_connect_click,
|
||||
eventData
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
connectionButtonClicked: (
|
||||
oldValue,
|
||||
newValue,
|
||||
metadata,
|
||||
trackingService
|
||||
) => {
|
||||
if ( newValue === true && oldValue === false ) {
|
||||
const eventData = {
|
||||
step_number: metadata.currentStep,
|
||||
step_name: metadata.stepName,
|
||||
...trackingService.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
trackingService.sendToAdapters(
|
||||
EVENTS.complete_connect_click,
|
||||
eventData
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
useSandbox: ( oldValue, newValue, metadata, trackingService ) => {
|
||||
if ( newValue === null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandboxMode = newValue === true ? 'enabled' : 'disabled';
|
||||
const eventData = {
|
||||
selected_value: sandboxMode,
|
||||
...trackingService.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
trackingService.sendToAdapters( EVENTS.sandbox_mode_select, eventData );
|
||||
},
|
||||
|
||||
useManualConnection: ( oldValue, newValue, metadata, trackingService ) => {
|
||||
if ( newValue === null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manualMode = newValue === true ? 'enabled' : 'disabled';
|
||||
const eventData = {
|
||||
selected_value: manualMode,
|
||||
...trackingService.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
trackingService.sendToAdapters(
|
||||
EVENTS.manual_connection_select,
|
||||
eventData
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Field tracking configurations using generic helpers.
|
||||
const createStepTrackingConfig = () => {
|
||||
return createSystemFieldTrackingConfig( 'step', 'persistent', {
|
||||
transform: ( value ) => ( {
|
||||
step_number: value,
|
||||
step_name: STEP_INFO[ value ]?.name || `step_${ value }`,
|
||||
} ),
|
||||
} );
|
||||
};
|
||||
|
||||
const createAccountTypeTrackingConfig = () => {
|
||||
return createFieldTrackingConfig( 'isCasualSeller', 'persistent', {
|
||||
transform: ( value ) => ( {
|
||||
selected_value: value === true ? 'personal' : 'business',
|
||||
} ),
|
||||
} );
|
||||
};
|
||||
|
||||
const createProductsTrackingConfig = () => {
|
||||
return createArrayFieldTrackingConfig( 'products', 'persistent' );
|
||||
};
|
||||
|
||||
const createPaymentOptionsTrackingConfig = () => {
|
||||
return createFieldTrackingConfig(
|
||||
'areOptionalPaymentMethodsEnabled',
|
||||
'persistent',
|
||||
{
|
||||
transform: ( value ) => ( {
|
||||
selected_value: value === true ? 'expanded' : 'no_cards',
|
||||
} ),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createCompletedTrackingConfig = () => {
|
||||
return createFieldTrackingConfig( 'completed', 'persistent', {
|
||||
transform: ( value ) => ( {
|
||||
completed: value === true,
|
||||
} ),
|
||||
} );
|
||||
};
|
||||
|
||||
const createConnectionButtonTrackingConfig = () => {
|
||||
return createTransientFieldTrackingConfig( 'connectionButtonClicked' );
|
||||
};
|
||||
|
||||
const createSandboxTrackingConfig = () => {
|
||||
return createBooleanFieldTrackingConfig( 'useSandbox', 'persistent' );
|
||||
};
|
||||
|
||||
const createManualConnectionTrackingConfig = () => {
|
||||
return createBooleanFieldTrackingConfig(
|
||||
'useManualConnection',
|
||||
'persistent'
|
||||
);
|
||||
};
|
||||
|
||||
// Main funnel configuration.
|
||||
export const config = FunnelConfigBuilder.createBasicFunnel( FUNNEL_ID, {
|
||||
debug: false,
|
||||
adapters: [ 'woocommerce-tracks' ],
|
||||
eventPrefix: 'ppcp_onboarding',
|
||||
// Only track for onboarding flow (isConnected: false).
|
||||
trackingCondition: {
|
||||
store: 'wc/paypal/common',
|
||||
selector: 'merchant',
|
||||
field: 'isConnected',
|
||||
expectedValue: false,
|
||||
},
|
||||
} )
|
||||
.addEvents( EVENTS )
|
||||
.addTranslations( TRANSLATIONS )
|
||||
.addStepInfo( STEP_INFO )
|
||||
.addStore( 'wc/paypal/onboarding', [
|
||||
createStepTrackingConfig(),
|
||||
createAccountTypeTrackingConfig(),
|
||||
createProductsTrackingConfig(),
|
||||
createPaymentOptionsTrackingConfig(),
|
||||
createCompletedTrackingConfig(),
|
||||
createConnectionButtonTrackingConfig(),
|
||||
] )
|
||||
.addStore( 'wc/paypal/common', [
|
||||
createSandboxTrackingConfig(),
|
||||
createManualConnectionTrackingConfig(),
|
||||
] )
|
||||
.build();
|
|
@ -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';
|
34
modules/ppcp-settings/resources/js/services/tracking/init.js
Normal file
34
modules/ppcp-settings/resources/js/services/tracking/init.js
Normal 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 };
|
446
modules/ppcp-settings/resources/js/services/tracking/registry.js
Normal file
446
modules/ppcp-settings/resources/js/services/tracking/registry.js
Normal 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',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* Funnel Tracking Service: Generic tracking service with funnel-specific translations.
|
||||
*
|
||||
* Processes state changes and routes events through funnel-specific translation functions.
|
||||
* Manages adapters, session tracking, and provides field-level source filtering.
|
||||
*
|
||||
* @file
|
||||
*/
|
||||
|
||||
export class FunnelTrackingService {
|
||||
/**
|
||||
* @param {Object} funnelConfig - Funnel configuration object.
|
||||
* @param {Object} [options={}] - Optional configuration.
|
||||
* @param {boolean} [options.debugMode] - Enable debug mode.
|
||||
*/
|
||||
constructor( funnelConfig, options = {} ) {
|
||||
this.funnelConfig = funnelConfig;
|
||||
this.adapters = [];
|
||||
this.sessionStartTime = Date.now();
|
||||
this.sessionId = this.generateSessionId();
|
||||
this.eventCount = 0;
|
||||
this.debugMode = options.debugMode || false;
|
||||
|
||||
// Get funnel-specific configurations.
|
||||
this.events = funnelConfig.events || {};
|
||||
this.translations = funnelConfig.translations || {};
|
||||
this.stepInfo = funnelConfig.stepInfo || {};
|
||||
|
||||
// Sources that are completely ignored.
|
||||
this.ignoredSources = new Set( [
|
||||
'subscription',
|
||||
'unknown',
|
||||
undefined,
|
||||
'', // Empty source.
|
||||
] );
|
||||
|
||||
// Force debug for PayPal funnels.
|
||||
if (
|
||||
funnelConfig.funnelId?.includes( 'ppcp' ) ||
|
||||
funnelConfig.funnelId?.includes( 'paypal' )
|
||||
) {
|
||||
this.debugMode = true;
|
||||
}
|
||||
|
||||
// Expose globally for debugging.
|
||||
if ( typeof window !== 'undefined' ) {
|
||||
window.funnelTrackingService = this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID.
|
||||
* @return {string} Generated session ID.
|
||||
*/
|
||||
generateSessionId() {
|
||||
return `${
|
||||
this.funnelConfig.eventPrefix || 'tracking'
|
||||
}_${ Date.now() }_${ Math.random().toString( 36 ).slice( 2, 11 ) }`;
|
||||
}
|
||||
/**
|
||||
* Register a tracking adapter.
|
||||
* @param {Object} adapter - Tracking adapter instance.
|
||||
*/
|
||||
addAdapter( adapter ) {
|
||||
this.adapters.push( adapter );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all adapters.
|
||||
*/
|
||||
clearAdapters() {
|
||||
this.adapters = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered adapters.
|
||||
* @return {Array} Array of adapter information.
|
||||
*/
|
||||
getAdapters() {
|
||||
return this.adapters.map(
|
||||
( adapter ) => adapter.getInfo?.() || adapter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process state changes using funnel-specific logic.
|
||||
* @param {Object} changeEvent - State change event object.
|
||||
* @param {string} changeEvent.field - Field name that changed.
|
||||
* @param {*} changeEvent.oldValue - Previous value.
|
||||
* @param {*} changeEvent.newValue - New value.
|
||||
* @param {Object} changeEvent.metadata - Additional metadata.
|
||||
* @param {string|Object} changeEvent.action - Action that triggered the change.
|
||||
*/
|
||||
processStateChange( changeEvent ) {
|
||||
const { field, oldValue, newValue, metadata, action } = changeEvent;
|
||||
|
||||
// Skip if no actual change.
|
||||
if ( oldValue === newValue ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different types of action input.
|
||||
let source;
|
||||
let actionType;
|
||||
|
||||
if ( typeof action === 'string' ) {
|
||||
source = metadata?.source || '';
|
||||
actionType = action;
|
||||
} else if ( action && typeof action === 'object' && action.type ) {
|
||||
source = action.source || 'unknown';
|
||||
actionType = action.type;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName =
|
||||
field ||
|
||||
( action?.payload
|
||||
? Object.keys( action.payload )[ 0 ]
|
||||
: 'unknown' );
|
||||
|
||||
// Filter based on field-specific source rules.
|
||||
const shouldTrack = this.shouldTrackFieldSource( fieldName, source );
|
||||
|
||||
if ( ! shouldTrack ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use funnel-specific translation if available.
|
||||
this.processTrackedChange( fieldName, oldValue, newValue, {
|
||||
...metadata,
|
||||
source,
|
||||
actionType,
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a field/source combination should be tracked.
|
||||
* @param {string} fieldName - Name of the field.
|
||||
* @param {string} source - Source of the change.
|
||||
* @return {boolean} Whether the field/source should be tracked.
|
||||
*/
|
||||
shouldTrackFieldSource( fieldName, source ) {
|
||||
// Ignore completely blocked sources.
|
||||
if ( this.ignoredSources.has( source ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find field rules in funnel configuration.
|
||||
const fieldRules = this.findFieldRules( fieldName );
|
||||
|
||||
if ( fieldRules ) {
|
||||
return fieldRules.allowedSources.includes( source );
|
||||
}
|
||||
|
||||
// Unknown fields are not tracked.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find field rules from funnel configuration.
|
||||
* @param {string} fieldName - Name of the field.
|
||||
* @return {Object|null} Field rules object or null if not found.
|
||||
*/
|
||||
findFieldRules( fieldName ) {
|
||||
for ( const storeName in this.funnelConfig.fieldConfigs ) {
|
||||
const storeFields = this.funnelConfig.fieldConfigs[ storeName ];
|
||||
const fieldConfig = storeFields.find(
|
||||
( f ) => f.fieldName === fieldName
|
||||
);
|
||||
|
||||
if ( fieldConfig && fieldConfig.rules ) {
|
||||
return fieldConfig.rules;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tracked changes using funnel-specific translations.
|
||||
* @param {string} fieldName - Name of the field.
|
||||
* @param {*} oldValue - Previous value.
|
||||
* @param {*} newValue - New value.
|
||||
* @param {Object} metadata - Additional metadata.
|
||||
*/
|
||||
processTrackedChange( fieldName, oldValue, newValue, metadata ) {
|
||||
// Check if funnel has a specific translation for this field.
|
||||
const translationFn = this.translations[ fieldName ];
|
||||
|
||||
if ( translationFn && typeof translationFn === 'function' ) {
|
||||
// Use funnel-specific translation.
|
||||
try {
|
||||
translationFn( oldValue, newValue, metadata, this );
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[Funnel Tracking] Error in translation for ${ fieldName }:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Fallback to generic tracking.
|
||||
this.genericFieldTracking(
|
||||
fieldName,
|
||||
oldValue,
|
||||
newValue,
|
||||
metadata
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic field tracking when no specific translation exists.
|
||||
* @param {string} fieldName - Name of the field.
|
||||
* @param {*} oldValue - Previous value.
|
||||
* @param {*} newValue - New value.
|
||||
* @param {Object} metadata - Additional metadata.
|
||||
*/
|
||||
genericFieldTracking( fieldName, oldValue, newValue, metadata ) {
|
||||
const eventName = `${ fieldName }_change`;
|
||||
const properties = {
|
||||
field_name: fieldName,
|
||||
old_value: oldValue,
|
||||
new_value: newValue,
|
||||
source: metadata.source,
|
||||
...this.getCommonProperties( metadata ),
|
||||
};
|
||||
|
||||
this.sendToAdapters( eventName, properties );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common properties for all tracking events.
|
||||
* @param {Object} [metadata={}] - Additional metadata.
|
||||
* @return {Object} Common properties object.
|
||||
*/
|
||||
getCommonProperties( metadata = {} ) {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to all registered adapters.
|
||||
* @param {string} eventName - Name of the event.
|
||||
* @param {Object} properties - Event properties.
|
||||
*/
|
||||
sendToAdapters( eventName, properties ) {
|
||||
this.eventCount++;
|
||||
|
||||
this.adapters.forEach( ( adapter, index ) => {
|
||||
try {
|
||||
// Send only the essential properties.
|
||||
adapter.track( eventName, properties );
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[Funnel Tracking] Adapter ${ index } error:`,
|
||||
error,
|
||||
adapter.getInfo?.() || 'unknown adapter'
|
||||
);
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tracking service statistics.
|
||||
* @return {Object} Statistics object.
|
||||
*/
|
||||
getStats() {
|
||||
const stats = {
|
||||
sessionId: this.sessionId,
|
||||
sessionStartTime: this.sessionStartTime,
|
||||
sessionDuration: Date.now() - this.sessionStartTime,
|
||||
eventCount: this.eventCount,
|
||||
adaptersCount: this.adapters.length,
|
||||
debugMode: this.debugMode,
|
||||
funnelId: this.funnelConfig.funnelId,
|
||||
eventsAvailable: Object.keys( this.events ).length,
|
||||
translationsAvailable: Object.keys( this.translations ).length,
|
||||
fieldConfigStores: Object.keys(
|
||||
this.funnelConfig.fieldConfigs || {}
|
||||
),
|
||||
totalFieldConfigs: Object.values(
|
||||
this.funnelConfig.fieldConfigs || {}
|
||||
).reduce( ( total, configs ) => total + configs.length, 0 ),
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug helper to test field source tracking.
|
||||
* @param {string} fieldName - Name of the field to test.
|
||||
* @param {string} source - Source to test.
|
||||
* @return {Object} Test results object.
|
||||
*/
|
||||
testFieldSourceTracking( fieldName, source ) {
|
||||
return {
|
||||
field: fieldName,
|
||||
source,
|
||||
shouldTrack: this.shouldTrackFieldSource( fieldName, source ),
|
||||
fieldRules: this.findFieldRules( fieldName ),
|
||||
ignoredSources: Array.from( this.ignoredSources ),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create tracking service with funnel configuration.
|
||||
* @param {Object} funnelConfig - Funnel configuration object.
|
||||
* @param {Object} [options={}] - Optional configuration.
|
||||
* @return {FunnelTrackingService} New tracking service instance.
|
||||
*/
|
||||
export function createFunnelTrackingService( funnelConfig, options = {} ) {
|
||||
return new FunnelTrackingService( funnelConfig, options );
|
||||
}
|
|
@ -0,0 +1,852 @@
|
|||
/**
|
||||
* Subscription Manager: Coordinates multiple funnels tracking the same store.
|
||||
*
|
||||
* Manages unified subscriptions to prevent conflicts when multiple tracking funnels
|
||||
* monitor the same WordPress data store. Handles condition evaluation and state coordination.
|
||||
*
|
||||
* @file
|
||||
*/
|
||||
|
||||
import { shouldTrackFieldSource, getFieldValue } from './utils';
|
||||
|
||||
/**
|
||||
* Manages unified subscriptions for stores that are tracked by multiple funnels.
|
||||
*/
|
||||
export class SubscriptionManager {
|
||||
constructor() {
|
||||
// Store name -> subscription info.
|
||||
this.storeSubscriptions = {};
|
||||
|
||||
// Store name -> array of funnel registrations.
|
||||
this.storeRegistrations = {};
|
||||
|
||||
this.debugMode = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a funnel's interest in tracking a store.
|
||||
* @param {string} storeName - Name of the store.
|
||||
* @param {string} funnelId - ID of the funnel.
|
||||
* @param {Object} trackingService - Funnel's tracking service.
|
||||
* @param {Object} fieldRules - Field rules for this funnel.
|
||||
* @param {Array} fieldConfigs - Field configurations for this funnel.
|
||||
* @param {boolean} debugMode - Debug mode for this funnel.
|
||||
* @param {Object|null} trackingCondition - Tracking condition for this funnel.
|
||||
* @param {Object} stepInfo - Step information for this funnel.
|
||||
*/
|
||||
registerFunnelForStore(
|
||||
storeName,
|
||||
funnelId,
|
||||
trackingService,
|
||||
fieldRules,
|
||||
fieldConfigs,
|
||||
debugMode,
|
||||
trackingCondition = null,
|
||||
stepInfo = {}
|
||||
) {
|
||||
// Initialize store registrations if needed.
|
||||
if ( ! this.storeRegistrations[ storeName ] ) {
|
||||
this.storeRegistrations[ storeName ] = [];
|
||||
}
|
||||
|
||||
// Check if already registered.
|
||||
const existingIndex = this.storeRegistrations[ storeName ].findIndex(
|
||||
( reg ) => reg.funnelId === funnelId
|
||||
);
|
||||
|
||||
const registration = {
|
||||
funnelId,
|
||||
trackingService,
|
||||
fieldRules,
|
||||
fieldConfigs,
|
||||
debugMode,
|
||||
trackingCondition,
|
||||
stepInfo,
|
||||
isActive: false,
|
||||
previousValues: {},
|
||||
hasTrackedPageLoad: false,
|
||||
initializationAttempts: 0,
|
||||
lastConditionResult: null,
|
||||
conditionCheckCount: 0,
|
||||
};
|
||||
|
||||
if ( existingIndex >= 0 ) {
|
||||
// Update existing registration.
|
||||
this.storeRegistrations[ storeName ][ existingIndex ] =
|
||||
registration;
|
||||
} else {
|
||||
// Add new registration.
|
||||
this.storeRegistrations[ storeName ].push( registration );
|
||||
}
|
||||
|
||||
// Enable debug if any funnel has it enabled.
|
||||
if ( debugMode ) {
|
||||
this.debugMode = true;
|
||||
}
|
||||
|
||||
// Create or update the unified subscription for this store.
|
||||
this.ensureStoreSubscription( storeName );
|
||||
|
||||
if ( this.debugMode ) {
|
||||
console.log(
|
||||
`[SubscriptionManager] Registered funnel ${ funnelId } for store ${ storeName }. ` +
|
||||
`Total funnels for this store: ${ this.storeRegistrations[ storeName ].length }`
|
||||
);
|
||||
}
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a unified subscription exists for a store.
|
||||
* @param {string} storeName - Name of the store.
|
||||
*/
|
||||
ensureStoreSubscription( storeName ) {
|
||||
// Skip if subscription already exists.
|
||||
if ( this.storeSubscriptions[ storeName ] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create unified subscription.
|
||||
const unsubscribe = wp.data.subscribe( () => {
|
||||
this.handleStoreChange( storeName );
|
||||
} );
|
||||
|
||||
this.storeSubscriptions[ storeName ] = {
|
||||
unsubscribe,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
if ( this.debugMode ) {
|
||||
console.log(
|
||||
`[SubscriptionManager] Created unified subscription for store ${ storeName }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle store changes and route to appropriate funnels.
|
||||
* @param {string} storeName - Name of the store that changed.
|
||||
*/
|
||||
handleStoreChange( storeName ) {
|
||||
try {
|
||||
const select = wp.data.select;
|
||||
const store = select( storeName );
|
||||
|
||||
if ( ! store ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all funnel registrations for this store.
|
||||
const registrations = this.storeRegistrations[ storeName ] || [];
|
||||
|
||||
// Process each funnel registration.
|
||||
registrations.forEach( ( registration ) => {
|
||||
try {
|
||||
this.processFunnelForStore(
|
||||
storeName,
|
||||
registration,
|
||||
select,
|
||||
store
|
||||
);
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[SubscriptionManager] Error processing funnel ${ registration.funnelId } for store ${ storeName }:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} );
|
||||
|
||||
// Handle field source clearing AFTER all funnels have processed.
|
||||
this.handleFieldSourceClearing( storeName, store, registrations );
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[SubscriptionManager] Error handling store change for ${ storeName }:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a specific funnel's tracking for a store change.
|
||||
* @param {string} storeName - Name of the store.
|
||||
* @param {Object} registration - Funnel registration object.
|
||||
* @param {Function} select - WordPress data select function.
|
||||
* @param {Object} store - Store object.
|
||||
*/
|
||||
processFunnelForStore( storeName, registration, select, store ) {
|
||||
const {
|
||||
funnelId,
|
||||
trackingService,
|
||||
fieldRules,
|
||||
fieldConfigs,
|
||||
trackingCondition,
|
||||
} = registration;
|
||||
|
||||
// Step 1: Evaluate tracking condition for this funnel.
|
||||
const conditionMet = this.evaluateTrackingCondition(
|
||||
select,
|
||||
trackingCondition,
|
||||
registration
|
||||
);
|
||||
|
||||
const shouldBeActive = this.handleConditionChange(
|
||||
registration,
|
||||
conditionMet
|
||||
);
|
||||
|
||||
if ( ! shouldBeActive ) {
|
||||
return; // Skip this funnel.
|
||||
}
|
||||
|
||||
// Step 2: Check initialization for this funnel.
|
||||
if ( ! registration.isActive ) {
|
||||
registration.initializationAttempts++;
|
||||
|
||||
if ( this.isStoreReadyForTracking( store, registration ) ) {
|
||||
registration.isActive = true;
|
||||
this.initializePreviousValues(
|
||||
select,
|
||||
storeName,
|
||||
fieldConfigs,
|
||||
registration.previousValues
|
||||
);
|
||||
|
||||
// Track initial page load if appropriate.
|
||||
if (
|
||||
! registration.hasTrackedPageLoad &&
|
||||
this.shouldTrackPageLoad( storeName ) &&
|
||||
conditionMet // Double-check condition.
|
||||
) {
|
||||
this.trackInitialPageLoad(
|
||||
select,
|
||||
storeName,
|
||||
trackingService,
|
||||
registration
|
||||
);
|
||||
registration.hasTrackedPageLoad = true;
|
||||
}
|
||||
} else {
|
||||
return; // Still waiting for initialization.
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Process field changes for this funnel.
|
||||
this.processFieldChangesForFunnel(
|
||||
select,
|
||||
store,
|
||||
storeName,
|
||||
registration,
|
||||
fieldConfigs,
|
||||
fieldRules,
|
||||
trackingService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate tracking condition for a specific funnel.
|
||||
* @param {Function} select - WordPress data select function.
|
||||
* @param {Object|null} trackingCondition - Tracking condition.
|
||||
* @param {Object} registration - Funnel registration.
|
||||
* @return {boolean} Whether condition is met.
|
||||
*/
|
||||
evaluateTrackingCondition( select, trackingCondition, registration ) {
|
||||
if ( ! trackingCondition ) {
|
||||
return true; // No condition = always track.
|
||||
}
|
||||
|
||||
registration.conditionCheckCount++;
|
||||
|
||||
try {
|
||||
const conditionStore = select( trackingCondition.store );
|
||||
if ( ! conditionStore ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check store readiness.
|
||||
const storeReadiness = conditionStore.transientData?.()?.isReady;
|
||||
if ( ! storeReadiness ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Execute selector.
|
||||
const selectorFn = conditionStore[ trackingCondition.selector ];
|
||||
if ( typeof selectorFn !== 'function' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectorResult = selectorFn();
|
||||
if ( ! selectorResult || typeof selectorResult !== 'object' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Evaluate condition.
|
||||
let conditionMet;
|
||||
if ( trackingCondition.field ) {
|
||||
const actualValue = selectorResult[ trackingCondition.field ];
|
||||
conditionMet = actualValue === trackingCondition.expectedValue;
|
||||
} else {
|
||||
const boolResult = !! selectorResult;
|
||||
const expectedBool = !! trackingCondition.expectedValue;
|
||||
conditionMet = boolResult === expectedBool;
|
||||
}
|
||||
|
||||
registration.lastConditionResult = conditionMet;
|
||||
return conditionMet;
|
||||
} catch ( error ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle condition state changes for a funnel.
|
||||
* @param {Object} registration - Funnel registration.
|
||||
* @param {boolean} conditionMet - Whether condition is currently met.
|
||||
* @return {boolean} Whether funnel should be active.
|
||||
*/
|
||||
handleConditionChange( registration, conditionMet ) {
|
||||
// Handle deactivation.
|
||||
if ( ! conditionMet && registration.isActive ) {
|
||||
this.resetFunnelState( registration );
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle reactivation.
|
||||
if ( conditionMet && ! registration.isActive ) {
|
||||
this.resetFunnelState( registration );
|
||||
}
|
||||
|
||||
return conditionMet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset funnel state for clean reinitialization.
|
||||
* @param {Object} registration - Funnel registration.
|
||||
*/
|
||||
resetFunnelState( registration ) {
|
||||
registration.isActive = false;
|
||||
registration.hasTrackedPageLoad = false;
|
||||
registration.initializationAttempts = 0;
|
||||
registration.previousValues = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if store is ready for tracking.
|
||||
* @param {Object} store - Store object.
|
||||
* @param {Object} registration - Funnel registration.
|
||||
* @return {boolean} Whether store is ready.
|
||||
*/
|
||||
isStoreReadyForTracking( store, registration ) {
|
||||
const isReady = store.transientData?.()?.isReady;
|
||||
const hasHydrationSource = store.getAllFieldSources?.()?.hydrate;
|
||||
|
||||
const hasFieldSourceMethods =
|
||||
typeof store.getFieldSource === 'function' &&
|
||||
typeof store.getAllFieldSources === 'function';
|
||||
|
||||
if ( ! hasFieldSourceMethods ) {
|
||||
return isReady || registration.initializationAttempts > 50;
|
||||
}
|
||||
|
||||
if ( isReady ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( hasHydrationSource ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return registration.initializationAttempts > 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize previous values for a funnel.
|
||||
* @param {Function} select - WordPress data select function.
|
||||
* @param {string} storeName - Store name.
|
||||
* @param {Array} fieldConfigs - Field configurations.
|
||||
* @param {Object} previousValues - Previous values object to populate.
|
||||
*/
|
||||
initializePreviousValues(
|
||||
select,
|
||||
storeName,
|
||||
fieldConfigs,
|
||||
previousValues
|
||||
) {
|
||||
fieldConfigs.forEach( ( fieldConfig ) => {
|
||||
try {
|
||||
const currentValue = getFieldValue(
|
||||
select,
|
||||
storeName,
|
||||
fieldConfig
|
||||
);
|
||||
previousValues[ fieldConfig.fieldName ] = currentValue;
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[SubscriptionManager] Error initializing ${ fieldConfig.fieldName }:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Track initial page load.
|
||||
* @param {Function} select - WordPress data select function.
|
||||
* @param {string} storeName - Store name.
|
||||
* @param {Object} trackingService - Tracking service.
|
||||
* @param {Object} registration - Funnel registration object.
|
||||
*/
|
||||
trackInitialPageLoad( select, storeName, trackingService, registration ) {
|
||||
try {
|
||||
const persistentData = select( storeName ).persistentData?.();
|
||||
const currentStep = persistentData?.step;
|
||||
|
||||
if ( typeof currentStep === 'number' ) {
|
||||
const metadata = this.createFunnelMetadata(
|
||||
select,
|
||||
registration
|
||||
);
|
||||
|
||||
trackingService.processStateChange( {
|
||||
field: 'step',
|
||||
oldValue: null,
|
||||
newValue: currentStep,
|
||||
action: {
|
||||
type: 'PAGE_LOAD',
|
||||
payload: { step: currentStep },
|
||||
source: 'system',
|
||||
},
|
||||
metadata,
|
||||
} );
|
||||
}
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[SubscriptionManager] Error tracking page load for ${ storeName }:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if should track page loads for a store.
|
||||
* @param {string} storeName - Store name.
|
||||
* @return {boolean} Whether to track page loads.
|
||||
*/
|
||||
shouldTrackPageLoad( storeName ) {
|
||||
return (
|
||||
storeName.includes( 'onboarding' ) || storeName.includes( 'wizard' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process field changes for a specific funnel.
|
||||
* @param {Function} select - WordPress data select function.
|
||||
* @param {Object} store - Store object.
|
||||
* @param {string} storeName - Store name.
|
||||
* @param {Object} registration - Funnel registration.
|
||||
* @param {Array} fieldConfigs - Field configurations.
|
||||
* @param {Object} fieldRules - Field rules.
|
||||
* @param {Object} trackingService - Tracking service.
|
||||
*/
|
||||
processFieldChangesForFunnel(
|
||||
select,
|
||||
store,
|
||||
storeName,
|
||||
registration,
|
||||
fieldConfigs,
|
||||
fieldRules,
|
||||
trackingService
|
||||
) {
|
||||
fieldConfigs.forEach( ( fieldConfig ) => {
|
||||
try {
|
||||
const currentValue = getFieldValue(
|
||||
select,
|
||||
storeName,
|
||||
fieldConfig
|
||||
);
|
||||
const previousValue =
|
||||
registration.previousValues[ fieldConfig.fieldName ];
|
||||
|
||||
// Skip if no change.
|
||||
if ( currentValue === previousValue ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get field source.
|
||||
const fieldSource =
|
||||
store.getFieldSource?.( fieldConfig.fieldName )?.source ||
|
||||
'';
|
||||
|
||||
// Check if this source should be tracked for this funnel.
|
||||
const shouldTrack = shouldTrackFieldSource(
|
||||
fieldConfig.fieldName,
|
||||
fieldSource,
|
||||
fieldRules
|
||||
);
|
||||
|
||||
if ( ! shouldTrack ) {
|
||||
// Update previous value but don't track.
|
||||
registration.previousValues[ fieldConfig.fieldName ] =
|
||||
currentValue;
|
||||
return;
|
||||
}
|
||||
|
||||
// Process tracked change for this funnel.
|
||||
this.processTrackedChangeForFunnel(
|
||||
fieldConfig,
|
||||
previousValue,
|
||||
currentValue,
|
||||
fieldSource,
|
||||
trackingService,
|
||||
select,
|
||||
storeName,
|
||||
registration
|
||||
);
|
||||
|
||||
// Update previous value.
|
||||
registration.previousValues[ fieldConfig.fieldName ] =
|
||||
currentValue;
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[SubscriptionManager] Error processing field ${ fieldConfig.fieldName } for funnel ${ registration.funnelId }:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a tracked change for a specific funnel.
|
||||
* @param {Object} fieldConfig - Field configuration.
|
||||
* @param {*} oldValue - Previous value.
|
||||
* @param {*} newValue - New value.
|
||||
* @param {string} source - Field source.
|
||||
* @param {Object} trackingService - Tracking service.
|
||||
* @param {Function} select - WordPress data select function.
|
||||
* @param {string} storeName - Store name.
|
||||
* @param {Object} registration - Funnel registration object.
|
||||
*/
|
||||
processTrackedChangeForFunnel(
|
||||
fieldConfig,
|
||||
oldValue,
|
||||
newValue,
|
||||
source,
|
||||
trackingService,
|
||||
select,
|
||||
storeName,
|
||||
registration
|
||||
) {
|
||||
const metadata = this.createFunnelMetadata( select, registration );
|
||||
|
||||
const action = {
|
||||
type:
|
||||
fieldConfig.type === 'transient'
|
||||
? 'SET_TRANSIENT'
|
||||
: 'SET_PERSISTENT',
|
||||
payload: { [ fieldConfig.fieldName ]: newValue },
|
||||
source,
|
||||
};
|
||||
|
||||
trackingService.processStateChange( {
|
||||
field: fieldConfig.fieldName,
|
||||
oldValue,
|
||||
newValue,
|
||||
action,
|
||||
metadata: {
|
||||
...metadata,
|
||||
detectedSource: source,
|
||||
},
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create aggregated metadata from all stores tracked by a funnel.
|
||||
* @param {Function} select - WordPress data select function.
|
||||
* @param {Object} registration - Funnel registration object.
|
||||
* @return {Object} Aggregated metadata from all funnel stores.
|
||||
*/
|
||||
createFunnelMetadata( select, registration ) {
|
||||
try {
|
||||
// Start with base metadata.
|
||||
const metadata = {
|
||||
action: 'SUBSCRIBER_CHANGE',
|
||||
timestamp: Date.now(),
|
||||
funnelId: registration.funnelId,
|
||||
};
|
||||
|
||||
// Get all stores this funnel tracks from the registration.
|
||||
const funnelStores = this.getFunnelStores( registration.funnelId );
|
||||
|
||||
// Aggregate data from each store.
|
||||
funnelStores.forEach( ( storeName ) => {
|
||||
try {
|
||||
const store = select( storeName );
|
||||
if ( ! store ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get store data safely.
|
||||
const flags = this.safeStoreCall( store, 'flags', {} );
|
||||
const persistentData = this.safeStoreCall(
|
||||
store,
|
||||
'persistentData',
|
||||
{}
|
||||
);
|
||||
const transientData = this.safeStoreCall(
|
||||
store,
|
||||
'transientData',
|
||||
{}
|
||||
);
|
||||
|
||||
// Add store-specific metadata with store prefix to avoid conflicts.
|
||||
const storeKey = storeName.replace( 'wc/paypal/', '' );
|
||||
metadata[ `${ storeKey }_flags` ] = flags;
|
||||
metadata[ `${ storeKey }_isReady` ] = transientData.isReady;
|
||||
|
||||
// Spread all store data, with later stores potentially overriding earlier ones.
|
||||
// This maintains backward compatibility with existing translation functions.
|
||||
Object.assign( metadata, persistentData, transientData );
|
||||
|
||||
// Keep track of which stores contributed data.
|
||||
if ( ! metadata.contributingStores ) {
|
||||
metadata.contributingStores = [];
|
||||
}
|
||||
metadata.contributingStores.push( storeName );
|
||||
} catch ( error ) {
|
||||
console.warn(
|
||||
`[SubscriptionManager] Error getting metadata from store ${ storeName }:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} );
|
||||
|
||||
// Add step information after aggregation
|
||||
this.enhanceMetadataWithStepInfo( metadata, registration );
|
||||
|
||||
return metadata;
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[SubscriptionManager] Error creating funnel metadata for ${ registration.funnelId }:`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
error: 'funnel_metadata_creation_failed',
|
||||
errorMessage: error.message,
|
||||
timestamp: Date.now(),
|
||||
funnelId: registration.funnelId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance metadata with step information from funnel configuration
|
||||
* @param {Object} metadata - The metadata object to enhance
|
||||
* @param {Object} registration - Funnel registration object
|
||||
*/
|
||||
enhanceMetadataWithStepInfo( metadata, registration ) {
|
||||
try {
|
||||
// Get the step number from aggregated data
|
||||
const stepNumber = metadata.step;
|
||||
|
||||
// Get step info directly from registration
|
||||
const stepInfo = registration.stepInfo || {};
|
||||
|
||||
// Add step name if we can map it
|
||||
if ( typeof stepNumber === 'number' && stepInfo[ stepNumber ] ) {
|
||||
const stepData = stepInfo[ stepNumber ];
|
||||
metadata.stepName =
|
||||
typeof stepData === 'string' ? stepData : stepData.name;
|
||||
}
|
||||
|
||||
// Add currentStep alias for backward compatibility with existing translations
|
||||
metadata.currentStep = stepNumber;
|
||||
|
||||
// Ensure step fields are properly typed (not string 'null')
|
||||
if ( stepNumber === null || stepNumber === undefined ) {
|
||||
metadata.step = null;
|
||||
metadata.currentStep = null;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log(
|
||||
`[SubscriptionManager] Step enhancement debug for ${ registration.funnelId }:`,
|
||||
{
|
||||
stepNumber,
|
||||
stepNumberType: typeof stepNumber,
|
||||
currentStep: metadata.currentStep,
|
||||
stepName: metadata.stepName,
|
||||
stepInfo,
|
||||
stepInfoKeys: Object.keys( stepInfo ),
|
||||
allMetadataKeys: Object.keys( metadata ),
|
||||
contributingStores: metadata.contributingStores,
|
||||
}
|
||||
);
|
||||
} catch ( error ) {
|
||||
console.warn(
|
||||
`[SubscriptionManager] Error enhancing metadata with step info:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stores tracked by a specific funnel.
|
||||
* @param {string} funnelId - The funnel ID.
|
||||
* @return {Array} Array of store names.
|
||||
*/
|
||||
getFunnelStores( funnelId ) {
|
||||
const stores = [];
|
||||
Object.entries( this.storeRegistrations ).forEach(
|
||||
( [ storeName, registrations ] ) => {
|
||||
if (
|
||||
registrations.some( ( reg ) => reg.funnelId === funnelId )
|
||||
) {
|
||||
stores.push( storeName );
|
||||
}
|
||||
}
|
||||
);
|
||||
return stores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely call a store method with fallback (moved from utils).
|
||||
* @param {Object} store - The store object.
|
||||
* @param {string} method - The method name to call.
|
||||
* @param {*} fallback - Fallback value if method fails.
|
||||
* @return {*} Method result or fallback.
|
||||
*/
|
||||
safeStoreCall( store, method, fallback = null ) {
|
||||
try {
|
||||
if ( typeof store[ method ] === 'function' ) {
|
||||
const result = store[ method ]();
|
||||
return result !== undefined ? result : fallback;
|
||||
}
|
||||
return fallback;
|
||||
} catch ( error ) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle field source clearing after all funnels have processed.
|
||||
* @param {string} storeName - Store name.
|
||||
* @param {Object} store - Store object.
|
||||
* @param {Array} registrations - All funnel registrations for this store.
|
||||
*/
|
||||
handleFieldSourceClearing( storeName, store, registrations ) {
|
||||
if ( ! store.clearFieldSource || ! store.getAllFieldSources ) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all field sources that were processed.
|
||||
const allFieldSources = store.getAllFieldSources() || {};
|
||||
|
||||
// Collect all field names that any active funnel is tracking.
|
||||
const trackedFields = new Set();
|
||||
registrations.forEach( ( registration ) => {
|
||||
if ( registration.isActive ) {
|
||||
registration.fieldConfigs.forEach( ( fieldConfig ) => {
|
||||
trackedFields.add( fieldConfig.fieldName );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
|
||||
// Clear sources for fields that were tracked.
|
||||
Object.keys( allFieldSources ).forEach( ( fieldName ) => {
|
||||
if ( trackedFields.has( fieldName ) ) {
|
||||
store.clearFieldSource( fieldName );
|
||||
}
|
||||
} );
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[SubscriptionManager] Error clearing field sources for ${ storeName }:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a funnel from a store.
|
||||
* @param {string} storeName - Store name.
|
||||
* @param {string} funnelId - Funnel ID.
|
||||
*/
|
||||
unregisterFunnelForStore( storeName, funnelId ) {
|
||||
const registrations = this.storeRegistrations[ storeName ];
|
||||
if ( ! registrations ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = registrations.findIndex(
|
||||
( reg ) => reg.funnelId === funnelId
|
||||
);
|
||||
if ( index >= 0 ) {
|
||||
registrations.splice( index, 1 );
|
||||
|
||||
if ( this.debugMode ) {
|
||||
console.log(
|
||||
`[SubscriptionManager] Unregistered funnel ${ funnelId } from store ${ storeName }. ` +
|
||||
`Remaining funnels: ${ registrations.length }`
|
||||
);
|
||||
}
|
||||
|
||||
// If no more funnels for this store, clean up subscription.
|
||||
if ( registrations.length === 0 ) {
|
||||
this.cleanupStoreSubscription( storeName );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up subscription for a store.
|
||||
* @param {string} storeName - Store name.
|
||||
*/
|
||||
cleanupStoreSubscription( storeName ) {
|
||||
const subscription = this.storeSubscriptions[ storeName ];
|
||||
if ( subscription ) {
|
||||
subscription.unsubscribe();
|
||||
delete this.storeSubscriptions[ storeName ];
|
||||
delete this.storeRegistrations[ storeName ];
|
||||
|
||||
if ( this.debugMode ) {
|
||||
console.log(
|
||||
`[SubscriptionManager] Cleaned up subscription for store ${ storeName }`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status information for debugging.
|
||||
* @return {Object} Status information.
|
||||
*/
|
||||
getStatus() {
|
||||
const status = {
|
||||
storesTracked: Object.keys( this.storeSubscriptions ).length,
|
||||
activeSubscriptions: Object.keys( this.storeSubscriptions ).filter(
|
||||
( storeName ) => this.storeSubscriptions[ storeName ].isActive
|
||||
).length,
|
||||
totalFunnelRegistrations: 0,
|
||||
storeDetails: {},
|
||||
};
|
||||
|
||||
Object.entries( this.storeRegistrations ).forEach(
|
||||
( [ storeName, registrations ] ) => {
|
||||
status.totalFunnelRegistrations += registrations.length;
|
||||
status.storeDetails[ storeName ] = {
|
||||
funnelCount: registrations.length,
|
||||
funnels: registrations.map( ( reg ) => ( {
|
||||
funnelId: reg.funnelId,
|
||||
isActive: reg.isActive,
|
||||
conditionMet: reg.lastConditionResult,
|
||||
conditionChecks: reg.conditionCheckCount,
|
||||
} ) ),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionManager = new SubscriptionManager();
|
108
modules/ppcp-settings/resources/js/services/tracking/utils.js
Normal file
108
modules/ppcp-settings/resources/js/services/tracking/utils.js
Normal file
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Utilities: Essential tracking helper functions.
|
||||
*
|
||||
* Provides helper functions for the tracking system.
|
||||
* Includes field validation, metadata creation, and store utilities.
|
||||
*
|
||||
* @file
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determines if a field with a given source should be tracked.
|
||||
*
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @param {string} source - The source of the field change.
|
||||
* @param {Object} storeRules - Rules for the specific store.
|
||||
* @return {boolean} Whether the field should be tracked.
|
||||
*/
|
||||
export function shouldTrackFieldSource( fieldName, source, storeRules ) {
|
||||
if ( ! source ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! storeRules ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! storeRules[ fieldName ] ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get and validate field rules.
|
||||
const fieldRule = storeRules[ fieldName ];
|
||||
const allowedSources = fieldRule.allowedSources;
|
||||
|
||||
if ( ! Array.isArray( allowedSources ) ) {
|
||||
console.warn(
|
||||
`[TRACK CHECK] Invalid allowedSources for ${ fieldName }:`,
|
||||
{ allowedSources, type: typeof allowedSources }
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( allowedSources.length === 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if source is allowed.
|
||||
return allowedSources.includes( source );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a field from the store with comprehensive error handling.
|
||||
*
|
||||
* @param {Function} select - WordPress data select function.
|
||||
* @param {string} storeName - The name of the store.
|
||||
* @param {Object} fieldConfig - Configuration for the field.
|
||||
* @return {*} The field value
|
||||
*/
|
||||
export function getFieldValue( select, storeName, fieldConfig ) {
|
||||
try {
|
||||
// Use custom selector if provided.
|
||||
if ( typeof fieldConfig.selector === 'function' ) {
|
||||
return fieldConfig.selector( select, storeName );
|
||||
}
|
||||
|
||||
// Get the store.
|
||||
const store = select( storeName );
|
||||
if ( ! store ) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Determine data source.
|
||||
const dataType = fieldConfig.type || 'persistent';
|
||||
let data;
|
||||
|
||||
if ( dataType === 'persistent' ) {
|
||||
data = store.persistentData?.();
|
||||
} else if ( dataType === 'transient' ) {
|
||||
data = store.transientData?.();
|
||||
} else {
|
||||
console.warn( `[FIELD VALUE] Unknown data type: ${ dataType }` );
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if ( ! data || typeof data !== 'object' ) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle nested field paths.
|
||||
const fieldPath = fieldConfig.fieldName.split( '.' );
|
||||
let value = data;
|
||||
|
||||
for ( const key of fieldPath ) {
|
||||
if ( value === null || value === undefined ) {
|
||||
return undefined;
|
||||
}
|
||||
value = value[ key ];
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch ( error ) {
|
||||
console.error(
|
||||
`[FIELD VALUE] Error getting value for ${ fieldConfig.fieldName }:`,
|
||||
error
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,475 @@
|
|||
/**
|
||||
* Field Configuration Helpers: Generic utilities for creating field tracking configurations.
|
||||
*
|
||||
* Provides helper functions and builder patterns for defining field tracking rules.
|
||||
* Includes validation helpers, transform patterns, and configuration builders for funnels.
|
||||
*
|
||||
* @file
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a standard field tracking configuration.
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @param {string} type - The type of field ('persistent' or 'transient').
|
||||
* @param {Object} options - Additional configuration options.
|
||||
* @return {Object} Field tracking configuration object.
|
||||
*/
|
||||
export const createFieldTrackingConfig = (
|
||||
fieldName,
|
||||
type = 'persistent',
|
||||
options = {}
|
||||
) => {
|
||||
return {
|
||||
fieldName,
|
||||
type,
|
||||
selector:
|
||||
options.selector ||
|
||||
( ( select, storeName ) => {
|
||||
const data =
|
||||
type === 'persistent'
|
||||
? select( storeName ).persistentData()
|
||||
: select( storeName ).transientData();
|
||||
return data?.[ fieldName ];
|
||||
} ),
|
||||
rules: {
|
||||
allowedSources: [ 'user' ], // Default to user-only.
|
||||
...options.rules,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a field tracking config that allows both user and system sources.
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @param {string} type - The type of field ('persistent' or 'transient').
|
||||
* @param {Object} options - Additional configuration options.
|
||||
* @return {Object} Field tracking configuration object.
|
||||
*/
|
||||
export const createSystemFieldTrackingConfig = (
|
||||
fieldName,
|
||||
type = 'persistent',
|
||||
options = {}
|
||||
) => {
|
||||
return createFieldTrackingConfig( fieldName, type, {
|
||||
...options,
|
||||
rules: {
|
||||
allowedSources: [ 'user', 'system' ],
|
||||
...options.rules,
|
||||
},
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a transient field tracking config (for fields stored in transientData).
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @param {Object} options - Additional configuration options.
|
||||
* @return {Object} Field tracking configuration object.
|
||||
*/
|
||||
export const createTransientFieldTrackingConfig = (
|
||||
fieldName,
|
||||
options = {}
|
||||
) => {
|
||||
return createFieldTrackingConfig( fieldName, 'transient', options );
|
||||
};
|
||||
|
||||
/**
|
||||
* Create multiple field tracking configs with the same base configuration.
|
||||
* @param {Array} fieldNames - Array of field names.
|
||||
* @param {string} type - The type of field ('persistent' or 'transient').
|
||||
* @param {Object} baseOptions - Base configuration options.
|
||||
* @return {Array} Array of field tracking configuration objects.
|
||||
*/
|
||||
export const createFieldTrackingConfigs = (
|
||||
fieldNames,
|
||||
type = 'persistent',
|
||||
baseOptions = {}
|
||||
) => {
|
||||
return fieldNames.map( ( fieldName ) =>
|
||||
createFieldTrackingConfig( fieldName, type, baseOptions )
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tracking config for nested field data.
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @param {string} path - Dot-separated path to nested field.
|
||||
* @param {string} type - The type of field ('persistent' or 'transient').
|
||||
* @param {Object} options - Additional configuration options.
|
||||
* @return {Object} Field tracking configuration object.
|
||||
*/
|
||||
export const createNestedFieldTrackingConfig = (
|
||||
fieldName,
|
||||
path,
|
||||
type = 'persistent',
|
||||
options = {}
|
||||
) => {
|
||||
return createFieldTrackingConfig( fieldName, type, {
|
||||
...options,
|
||||
selector: ( select, storeName ) => {
|
||||
const data =
|
||||
type === 'persistent'
|
||||
? select( storeName ).persistentData()
|
||||
: select( storeName ).transientData();
|
||||
return path
|
||||
.split( '.' )
|
||||
.reduce( ( obj, key ) => obj?.[ key ], data );
|
||||
},
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tracking config for boolean fields with value mapping.
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @param {string} type - The type of field ('persistent' or 'transient').
|
||||
* @param {string} trueValue - Value to use for true.
|
||||
* @param {string} falseValue - Value to use for false.
|
||||
* @return {Object} Field tracking configuration object.
|
||||
*/
|
||||
export const createBooleanFieldTrackingConfig = (
|
||||
fieldName,
|
||||
type = 'persistent',
|
||||
trueValue = 'enabled',
|
||||
falseValue = 'disabled'
|
||||
) => {
|
||||
return createFieldTrackingConfig( fieldName, type, {
|
||||
transform: ( value ) => {
|
||||
let selectedValue;
|
||||
if ( value === true ) {
|
||||
selectedValue = trueValue;
|
||||
} else if ( value === false ) {
|
||||
selectedValue = falseValue;
|
||||
} else {
|
||||
selectedValue = 'not_selected';
|
||||
}
|
||||
return { selected_value: selectedValue };
|
||||
},
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tracking config for array fields.
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @param {string} type - The type of field ('persistent' or 'transient').
|
||||
* @param {Object} options - Additional configuration options.
|
||||
* @return {Object} Field tracking configuration object.
|
||||
*/
|
||||
export const createArrayFieldTrackingConfig = (
|
||||
fieldName,
|
||||
type = 'persistent',
|
||||
options = {}
|
||||
) => {
|
||||
return createFieldTrackingConfig( fieldName, type, {
|
||||
...options,
|
||||
transform: ( value ) => ( {
|
||||
selected_items: Array.isArray( value ) ? value.join( ',' ) : 'none',
|
||||
items_count: Array.isArray( value ) ? value.length : 0,
|
||||
...( options.transform ? options.transform( value ) : {} ),
|
||||
} ),
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tracking config for trigger fields (like button clicks).
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @param {string} type - The type of field ('persistent' or 'transient').
|
||||
* @param {Object} options - Additional configuration options.
|
||||
* @return {Object} Field tracking configuration object.
|
||||
*/
|
||||
export const createTriggerFieldTrackingConfig = (
|
||||
fieldName,
|
||||
type = 'transient',
|
||||
options = {}
|
||||
) => {
|
||||
return createFieldTrackingConfig( fieldName, type, {
|
||||
...options,
|
||||
// Typically used with translation that checks oldValue === false && newValue === true.
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to create store field tracking configurations.
|
||||
* @param {string} storeName - Name of the store.
|
||||
* @param {Array|Object} fieldConfigs - Array of field configurations or single config.
|
||||
* @return {Object} Store tracking configuration object.
|
||||
*/
|
||||
export const createStoreTrackingConfig = ( storeName, fieldConfigs ) => {
|
||||
return {
|
||||
[ storeName ]: Array.isArray( fieldConfigs )
|
||||
? fieldConfigs
|
||||
: [ fieldConfigs ],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge multiple store tracking configurations.
|
||||
* @param {...Object} configs - Store tracking configuration objects to merge.
|
||||
* @return {Object} Merged store tracking configuration.
|
||||
*/
|
||||
export const mergeStoreTrackingConfigs = ( ...configs ) => {
|
||||
return Object.assign( {}, ...configs );
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic configuration builder for any funnel.
|
||||
*/
|
||||
export class FunnelConfigBuilder {
|
||||
/**
|
||||
* Create a new funnel configuration builder.
|
||||
* @param {string} funnelId - Unique identifier for the funnel.
|
||||
*/
|
||||
constructor( funnelId ) {
|
||||
this.funnelId = funnelId;
|
||||
this.config = {
|
||||
debug: false,
|
||||
adapters: [ 'console' ],
|
||||
eventPrefix: funnelId,
|
||||
events: {},
|
||||
translations: {},
|
||||
stepInfo: {},
|
||||
fieldConfigs: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set debug mode.
|
||||
* @param {boolean} debug - Whether to enable debug mode.
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
setDebug( debug = true ) {
|
||||
this.config.debug = debug;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set adapters for tracking.
|
||||
* @param {Array} adapters - Array of adapter names.
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
setAdapters( adapters ) {
|
||||
this.config.adapters = adapters;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event prefix.
|
||||
* @param {string} prefix - Prefix for event names.
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
setEventPrefix( prefix ) {
|
||||
this.config.eventPrefix = prefix;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add events to the configuration.
|
||||
* @param {Object} events - Object mapping event names to event identifiers.
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
addEvents( events ) {
|
||||
this.config.events = { ...this.config.events, ...events };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add translations to the configuration.
|
||||
* @param {Object} translations - Object mapping field names to translation functions.
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
addTranslations( translations ) {
|
||||
this.config.translations = {
|
||||
...this.config.translations,
|
||||
...translations,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add step information to the configuration.
|
||||
* @param {Object} stepInfo - Object mapping step numbers to step metadata.
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
addStepInfo( stepInfo ) {
|
||||
this.config.stepInfo = { ...this.config.stepInfo, ...stepInfo };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tracking condition for the funnel.
|
||||
* @param {Object} condition - Tracking condition configuration.
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
setTrackingCondition( condition ) {
|
||||
this.config.trackingCondition = condition;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add store tracking configuration.
|
||||
* @param {string} storeName - Name of the store.
|
||||
* @param {Array} fieldTrackingConfigs - Array of field tracking configurations.
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
addStore( storeName, fieldTrackingConfigs ) {
|
||||
this.config.fieldConfigs[ storeName ] = fieldTrackingConfigs;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge additional configuration.
|
||||
* @param {Object} additionalConfig - Additional configuration to merge
|
||||
* @return {FunnelConfigBuilder} Builder instance for chaining.
|
||||
*/
|
||||
mergeConfig( additionalConfig ) {
|
||||
this.config = { ...this.config, ...additionalConfig };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final configuration.
|
||||
* @return {Object} Complete funnel configuration.
|
||||
*/
|
||||
build() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for typical funnel setups.
|
||||
* @param {string} funnelId - Unique identifier for the funnel.
|
||||
* @param {Object} options - Configuration options.
|
||||
* @return {FunnelConfigBuilder} New builder instance.
|
||||
*/
|
||||
static createBasicFunnel( funnelId, options = {} ) {
|
||||
const builder = new FunnelConfigBuilder( funnelId )
|
||||
.setDebug( options.debug || false )
|
||||
.setAdapters( options.adapters || [ 'console' ] );
|
||||
|
||||
if ( options.eventPrefix ) {
|
||||
builder.setEventPrefix( options.eventPrefix );
|
||||
}
|
||||
|
||||
if ( options.trackingCondition ) {
|
||||
builder.setTrackingCondition( options.trackingCondition );
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common field tracking rule patterns.
|
||||
*/
|
||||
export const RulePatterns = {
|
||||
// Only user interactions.
|
||||
userOnly: { allowedSources: [ 'user' ] },
|
||||
|
||||
// User interactions and system changes.
|
||||
userAndSystem: { allowedSources: [ 'user', 'system' ] },
|
||||
|
||||
// Only system changes.
|
||||
systemOnly: { allowedSources: [ 'system' ] },
|
||||
|
||||
// Custom rule creator.
|
||||
custom: ( sources ) => ( { allowedSources: sources } ),
|
||||
};
|
||||
|
||||
/**
|
||||
* Common transform patterns for field tracking.
|
||||
*/
|
||||
export const TransformPatterns = {
|
||||
// Boolean to enabled/disabled.
|
||||
enabledDisabled: ( value ) => {
|
||||
let selectedValue;
|
||||
if ( value === true ) {
|
||||
selectedValue = 'enabled';
|
||||
} else if ( value === false ) {
|
||||
selectedValue = 'disabled';
|
||||
} else {
|
||||
selectedValue = 'not_selected';
|
||||
}
|
||||
return { selected_value: selectedValue };
|
||||
},
|
||||
|
||||
// Boolean to yes/no.
|
||||
yesNo: ( value ) => {
|
||||
let selectedValue;
|
||||
if ( value === true ) {
|
||||
selectedValue = 'yes';
|
||||
} else if ( value === false ) {
|
||||
selectedValue = 'no';
|
||||
} else {
|
||||
selectedValue = 'not_selected';
|
||||
}
|
||||
return { selected_value: selectedValue };
|
||||
},
|
||||
|
||||
// Array to comma-separated string with count.
|
||||
arrayWithCount: ( value ) => ( {
|
||||
selected_items: Array.isArray( value ) ? value.join( ',' ) : 'none',
|
||||
items_count: Array.isArray( value ) ? value.length : 0,
|
||||
} ),
|
||||
|
||||
// Just the raw value.
|
||||
passthrough: ( value ) => ( { selected_value: value } ),
|
||||
|
||||
// Custom transform creator.
|
||||
custom: ( transformFn ) => transformFn,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation helpers.
|
||||
*/
|
||||
export const ValidationHelpers = {
|
||||
// Validate field tracking configuration.
|
||||
validateFieldTrackingConfig: ( fieldConfig ) => {
|
||||
const errors = [];
|
||||
|
||||
if ( ! fieldConfig.fieldName ) {
|
||||
errors.push( 'fieldName is required' );
|
||||
}
|
||||
|
||||
if ( ! [ 'persistent', 'transient' ].includes( fieldConfig.type ) ) {
|
||||
errors.push( 'type must be "persistent" or "transient"' );
|
||||
}
|
||||
|
||||
if ( fieldConfig.rules && ! fieldConfig.rules.allowedSources ) {
|
||||
errors.push(
|
||||
'rules.allowedSources is required when rules are specified'
|
||||
);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
},
|
||||
|
||||
// Validate store tracking configuration.
|
||||
validateStoreTrackingConfig: ( storeConfig ) => {
|
||||
const errors = [];
|
||||
|
||||
Object.entries( storeConfig ).forEach(
|
||||
( [ storeName, fieldConfigs ] ) => {
|
||||
if ( ! Array.isArray( fieldConfigs ) ) {
|
||||
errors.push(
|
||||
`Field tracking configs for store ${ storeName } must be an array`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
fieldConfigs.forEach( ( fieldConfig, index ) => {
|
||||
const validation =
|
||||
ValidationHelpers.validateFieldTrackingConfig(
|
||||
fieldConfig
|
||||
);
|
||||
if ( ! validation.valid ) {
|
||||
errors.push(
|
||||
`Store ${ storeName }, field config ${ index }: ${ validation.errors.join(
|
||||
', '
|
||||
) }`
|
||||
);
|
||||
}
|
||||
} );
|
||||
}
|
||||
);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
},
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue