🏗️ Clean up the tracking architecture and add a separate tracking data store

This commit is contained in:
Daniel Dudzic 2025-06-16 13:11:10 +02:00
parent 13dfd8f704
commit 2ee3d68ecc
No known key found for this signature in database
GPG key ID: 31B40D33E3465483
22 changed files with 470 additions and 723 deletions

View file

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

View file

@ -36,34 +36,26 @@ 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} source Optional source context for tracking.
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setTransient = ( prop, value, source = '' ) => ( {
export const setTransient = ( prop, value ) => ( {
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} source Optional source context for tracking.
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setPersistent = ( prop, value, source = '' ) => {
return {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
source,
fieldName: prop,
};
};
export const setPersistent = ( prop, value ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
} );
/**
* Transient. Marks the onboarding details as "ready", i.e., fully initialized.
@ -86,22 +78,19 @@ 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, source ) => {
return setPersistent( 'useSandbox', useSandbox, source );
};
export const setSandboxMode = ( useSandbox ) =>
setPersistent( 'useSandbox', useSandbox );
/**
* 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, source ) =>
setPersistent( 'useManualConnection', useManualConnection, source );
export const setManualConnectionMode = ( useManualConnection ) =>
setPersistent( 'useManualConnection', useManualConnection );
/**
* Persistent. Changes the "webhooks" value.
@ -163,14 +152,3 @@ export const stopActivity = ( id ) => ( {
type: ACTION_TYPES.STOP_ACTIVITY,
payload: { id },
} );
/**
* Action creator to clear field source tracking.
*
* @param {string} fieldName - Name of the field to clear source for.
* @return {Action} The action.
*/
export const clearFieldSource = ( fieldName ) => ( {
type: ACTION_TYPES.CLEAR_FIELD_SOURCE,
payload: { fieldName },
} );

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,4 @@ export default {
// Gateway sync flag.
SYNC_GATEWAYS: 'ppcp/onboarding/SYNC_GATEWAYS',
REFRESH_GATEWAYS: 'ppcp/onboarding/REFRESH_GATEWAYS',
// Tracking.
CLEAR_FIELD_SOURCE: 'ppcp/onboarding/CLEAR_FIELD_SOURCE',
};

View file

@ -16,19 +16,14 @@ 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 = ( source ) => ( {
type: ACTION_TYPES.RESET,
source,
} );
export const reset = () => ( { type: ACTION_TYPES.RESET } );
/**
* Persistent. Set the full onboarding details, usually during app initialization.
@ -39,37 +34,30 @@ export const reset = ( source ) => ( {
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} source Optional source context for tracking.
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setTransient = ( prop, value, source = '' ) => ( {
export const setTransient = ( prop, value ) => ( {
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} source Optional source context for tracking.
* @param {string} prop Name of the property to update.
* @param {any} value The new value of the property.
* @return {Action} The action.
*/
export const setPersistent = ( prop, value, source ) => ( {
export const setPersistent = ( prop, value ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { [ prop ]: value },
source,
fieldName: prop,
} );
/**
@ -78,8 +66,7 @@ export const setPersistent = ( prop, value, source ) => ( {
* @param {boolean} isReady
* @return {Action} The action.
*/
export const setIsReady = ( isReady ) =>
setTransient( 'isReady', isReady, 'system' );
export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady );
/**
* Thunk action creator. Triggers the persistence of onboarding data to the server.
@ -117,139 +104,39 @@ export function refresh() {
/**
* Persistent. Updates the gateway synced status.
*
* @param {boolean} synced The sync status to set.
* @param {string} source Optional source context for tracking.
* @param {boolean} synced The sync status to set
* @return {Action} The action.
*/
export const updateGatewaysSynced = ( synced = true, source ) =>
setPersistent( 'gatewaysSynced', synced, source );
export const updateGatewaysSynced = ( synced = true ) =>
setPersistent( 'gatewaysSynced', synced );
/**
* Persistent. Updates the gateway refreshed status.
*
* @param {boolean} refreshed The refreshed status to set.
* @param {string} source Optional source context for tracking.
* @param {boolean} refreshed The refreshed status to set
* @return {Action} The action.
*/
export const updateGatewaysRefreshed = ( refreshed = true, source ) =>
setPersistent( 'gatewaysRefreshed', refreshed, source );
export const updateGatewaysRefreshed = ( refreshed = true ) =>
setPersistent( 'gatewaysRefreshed', refreshed );
/**
* 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( source ) {
export function syncGateways() {
return async ( { dispatch } ) => {
dispatch( setPersistent( 'gatewaysSynced', true, source ) );
dispatch( setPersistent( 'gatewaysSynced', true ) );
await dispatch.persist();
return { success: true };
};
}
/**
* Action creator to refresh payment gateways.
*
* @param {string} source Optional source context for tracking.
* @return {Function} The thunk function.
*/
export function refreshGateways( source ) {
export function refreshGateways() {
return async ( { dispatch } ) => {
dispatch( setPersistent( 'gatewaysRefreshed', true, source ) );
dispatch( setPersistent( 'gatewaysRefreshed', true ) );
await dispatch.persist();
return { success: true };
};
}
/**
* Action creator to clear field source tracking.
*
* @param {string} fieldName - Name of the field to clear source for.
* @return {Action} The action.
*/
export const clearFieldSource = ( fieldName ) => ( {
type: ACTION_TYPES.CLEAR_FIELD_SOURCE,
payload: { fieldName },
} );
/**
* Transient. Updates the connection button clicked status.
*
* @param {boolean} clicked Whether the button was clicked.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setConnectionButtonClicked = ( clicked = true, source = 'user' ) =>
setTransient( 'connectionButtonClicked', clicked, source );
/**
* Persistent. Updates the current step in the onboarding flow.
*
* @param {number} step The step number to set.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setStep = ( step, source ) =>
setPersistent( 'step', step, source );
/**
* Persistent. Updates the completed status of the onboarding.
*
* @param {boolean} completed Whether onboarding is completed.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setCompleted = ( completed, source ) =>
setPersistent( 'completed', completed, source );
/**
* Persistent. Updates the casual seller status.
*
* @param {boolean} isCasualSeller Whether the user is a casual seller.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setIsCasualSeller = ( isCasualSeller, source ) =>
setPersistent( 'isCasualSeller', isCasualSeller, source );
/**
* Persistent. Updates the optional payment methods setting.
*
* @param {boolean} enabled Whether optional payment methods are enabled.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setOptionalPaymentMethods = ( enabled, source ) =>
setPersistent( 'areOptionalPaymentMethodsEnabled', enabled, source );
/**
* Persistent. Updates the selected products.
*
* @param {Array} products Array of selected product types.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setProducts = ( products, source ) =>
setPersistent( 'products', products, source );
/**
* Transient. Updates the manual client ID.
*
* @param {string} clientId The manual client ID.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setManualClientId = ( clientId, source ) =>
setTransient( 'manualClientId', clientId, source );
/**
* Transient. Updates the manual client secret.
*
* @param {string} clientSecret The manual client secret.
* @param {string} source Optional source context for tracking.
* @return {Action} The action.
*/
export const setManualClientSecret = ( clientSecret, source ) =>
setTransient( 'manualClientSecret', clientSecret, source );

View file

@ -17,7 +17,6 @@ const defaultTransient = Object.freeze( {
manualClientId: '',
manualClientSecret: '',
connectionButtonClicked: false,
fieldSources: Object.freeze( {} ),
// Read only values, provided by the server.
flags: Object.freeze( {
@ -41,26 +40,6 @@ 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(
@ -69,48 +48,13 @@ const [ changeTransient, changePersistent ] = createReducerSetters(
);
const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload, action ) => {
const newState = changeTransient( state, payload );
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) =>
changeTransient( state, payload ),
if ( action && action.source ) {
const fieldName = Object.keys( payload )[ 0 ];
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) =>
changePersistent( state, payload ),
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 ) => {
[ ACTION_TYPES.RESET ]: ( state ) => {
const cleanState = changeTransient(
changePersistent( state, defaultPersistent ),
defaultTransient
@ -120,37 +64,11 @@ 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, 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 );
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
const newState = changePersistent( state, payload.data );
// Flags are not updated by `changePersistent()`.
if ( payload.flags ) {
@ -163,34 +81,12 @@ const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
return newState;
},
[ 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.SYNC_GATEWAYS ]: ( state ) => {
return changePersistent( state, { gatewaysSynced: 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;
[ ACTION_TYPES.REFRESH_GATEWAYS ]: ( state ) => {
return changePersistent( state, { gatewaysRefreshed: true } );
},
} );

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -216,11 +216,18 @@ const createAccountTypeTrackingConfig = () => {
transform: ( value ) => ( {
selected_value: value === true ? 'personal' : 'business',
} ),
rules: {
allowedSources: [ 'user' ],
},
} );
};
const createProductsTrackingConfig = () => {
return createArrayFieldTrackingConfig( 'products', 'persistent' );
return createArrayFieldTrackingConfig( 'products', 'persistent', {
rules: {
allowedSources: [ 'user' ],
},
} );
};
const createPaymentOptionsTrackingConfig = () => {
@ -231,6 +238,9 @@ const createPaymentOptionsTrackingConfig = () => {
transform: ( value ) => ( {
selected_value: value === true ? 'expanded' : 'no_cards',
} ),
rules: {
allowedSources: [ 'user' ],
},
}
);
};
@ -240,6 +250,9 @@ const createCompletedTrackingConfig = () => {
transform: ( value ) => ( {
completed: value === true,
} ),
rules: {
allowedSources: [ 'system' ],
},
} );
};
@ -248,13 +261,20 @@ const createConnectionButtonTrackingConfig = () => {
};
const createSandboxTrackingConfig = () => {
return createBooleanFieldTrackingConfig( 'useSandbox', 'persistent' );
return createBooleanFieldTrackingConfig(
'useSandbox',
'persistent',
'enabled',
'disabled'
);
};
const createManualConnectionTrackingConfig = () => {
return createBooleanFieldTrackingConfig(
'useManualConnection',
'persistent'
'persistent',
'enabled',
'disabled'
);
};

View file

@ -152,9 +152,8 @@ export class FunnelTrackingService {
if ( fieldRules ) {
return fieldRules.allowedSources.includes( source );
}
// Unknown fields are not tracked.
return false;
// No rules = accept all sources.
return true;
}
/**

View file

@ -7,7 +7,7 @@
* @file
*/
import { shouldTrackFieldSource, getFieldValue } from './utils';
import { getFieldValue } from './utils';
/**
* Manages unified subscriptions for stores that are tracked by multiple funnels.
@ -156,9 +156,6 @@ export class SubscriptionManager {
);
}
} );
// 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 }:`,
@ -175,13 +172,8 @@ export class SubscriptionManager {
* @param {Object} store - Store object.
*/
processFunnelForStore( storeName, registration, select, store ) {
const {
funnelId,
trackingService,
fieldRules,
fieldConfigs,
trackingCondition,
} = registration;
const { trackingService, fieldRules, fieldConfigs, trackingCondition } =
registration;
// Step 1: Evaluate tracking condition for this funnel.
const conditionMet = this.evaluateTrackingCondition(
@ -243,6 +235,86 @@ export class SubscriptionManager {
);
}
/**
* Process field changes for a specific funnel.
* Retrieves field sources from the tracking store to determine tracking eligibility.
* @param {Function} select - WordPress data select function.
* @param {Object} store - Store object.
* @param {string} storeName - Store name.
* @param {Object} registration - Funnel registration.
* @param {Array} fieldConfigs - Field configurations.
* @param {Object} fieldRules - Field rules.
* @param {Object} trackingService - Tracking service.
*/
processFieldChangesForFunnel(
select,
store,
storeName,
registration,
fieldConfigs,
fieldRules,
trackingService
) {
fieldConfigs.forEach( ( fieldConfig ) => {
try {
const currentValue = getFieldValue(
select,
storeName,
fieldConfig
);
const previousValue =
registration.previousValues[ fieldConfig.fieldName ];
// Skip if no change.
if ( currentValue === previousValue ) {
return;
}
// Get field source from the tracking store.
const trackingStore = select( 'wc/paypal/tracking' );
const fieldSource =
trackingStore?.getFieldSource?.(
storeName,
fieldConfig.fieldName
)?.source || '';
// Check if this source should be tracked for this funnel.
const shouldTrack = trackingService.shouldTrackFieldSource(
fieldConfig.fieldName,
fieldSource
);
if ( ! shouldTrack ) {
// Update previous value but don't track.
registration.previousValues[ fieldConfig.fieldName ] =
currentValue;
return;
}
// Process tracked change for this funnel.
this.processTrackedChangeForFunnel(
fieldConfig,
previousValue,
currentValue,
fieldSource,
trackingService,
select,
storeName,
registration
);
// Update previous value.
registration.previousValues[ fieldConfig.fieldName ] =
currentValue;
} catch ( error ) {
console.error(
`[SubscriptionManager] Error processing field ${ fieldConfig.fieldName } for funnel ${ registration.funnelId }:`,
error
);
}
} );
}
/**
* Evaluate tracking condition for a specific funnel.
* @param {Function} select - WordPress data select function.
@ -338,24 +410,12 @@ export class SubscriptionManager {
*/
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;
}
// Fallback for stores that take time to initialize.
return registration.initializationAttempts > 50;
}
@ -438,83 +498,6 @@ export class SubscriptionManager {
);
}
/**
* 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.
@ -620,7 +603,7 @@ export class SubscriptionManager {
}
} );
// Add step information after aggregation
// Add step information after aggregation.
this.enhanceMetadataWithStepInfo( metadata, registration );
return metadata;
@ -639,48 +622,33 @@ export class SubscriptionManager {
}
/**
* Enhance metadata with step information from funnel configuration
* @param {Object} metadata - The metadata object to enhance
* @param {Object} registration - Funnel registration object
* 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
// Get the step number from aggregated data.
const stepNumber = metadata.step;
// Get step info directly from registration
// Get step info directly from registration.
const stepInfo = registration.stepInfo || {};
// Add step name if we can map it
// 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
// Add currentStep alias for backward compatibility with existing translations.
metadata.currentStep = stepNumber;
// Ensure step fields are properly typed (not string 'null')
// 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:`,
@ -709,7 +677,7 @@ export class SubscriptionManager {
}
/**
* Safely call a store method with fallback (moved from utils).
* Safely call a store method with fallback.
* @param {Object} store - The store object.
* @param {string} method - The method name to call.
* @param {*} fallback - Fallback value if method fails.
@ -727,45 +695,6 @@ export class SubscriptionManager {
}
}
/**
* 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.

View file

@ -7,47 +7,6 @@
* @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.
*

View file

@ -31,10 +31,7 @@ export const createFieldTrackingConfig = (
: select( storeName ).transientData();
return data?.[ fieldName ];
} ),
rules: {
allowedSources: [ 'user' ], // Default to user-only.
...options.rules,
},
...( options.rules && { rules: options.rules } ),
...options,
};
};