Merge pull request #3139 from woocommerce/PCP-4244-add-logic-and-ui-visuals-for-conditional-disabled-state-for-payment-methods

Payment Methods: Add Dependency-Based Status Sync (4244)
This commit is contained in:
Emili Castells 2025-02-21 14:45:49 +01:00 committed by GitHub
commit 8f9e305fa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 748 additions and 112 deletions

View file

@ -82,3 +82,85 @@
}
}
}
// Disabled state styling.
.ppcp--method-item--disabled {
position: relative;
// Apply grayscale and disable interactions.
.ppcp--method-inner {
opacity: 0.7;
filter: grayscale(1);
pointer-events: none;
transition: filter 0.2s ease;
}
// Override text colors.
.ppcp--method-title {
color: $color-gray-700 !important;
}
.ppcp--method-description p {
color: $color-gray-500 !important;
}
.ppcp--method-disabled-message {
opacity: 0;
transform: translateY(-5px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
// Style all buttons and toggle controls.
.components-button,
.components-form-toggle {
opacity: 0.5;
}
// Hover state - only blur the inner content.
&:hover {
.ppcp--method-inner {
filter: blur(2px) grayscale(1);
}
.ppcp--method-disabled-message {
opacity: 1;
transform: translateY(0);
}
}
}
// Disabled overlay.
.ppcp--method-disabled-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba($color-white, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
border-radius: var(--container-border-radius);
pointer-events: auto;
opacity: 0;
transition: opacity 0.2s ease;
}
.ppcp--method-item--disabled:hover .ppcp--method-disabled-overlay {
opacity: 1;
}
.ppcp--method-disabled-message {
padding: 14px 18px;
text-align: center;
@include font(13, 20, 500);
color: $color-text-tertiary;
position: relative;
z-index: 51;
border: none;
a {
text-decoration: none;
}
}

View file

@ -11,6 +11,8 @@ const PaymentMethodItemBlock = ( {
onTriggerModal,
onSelect,
isSelected,
isDisabled,
disabledMessage,
} ) => {
const { activeHighlight, setActiveHighlight } = useActiveHighlight();
const isHighlighted = activeHighlight === paymentMethod.id;
@ -31,9 +33,16 @@ const PaymentMethodItemBlock = ( {
id={ paymentMethod.id }
className={ `ppcp--method-item ${
isHighlighted ? 'ppcp-highlight' : ''
}` }
} ${ isDisabled ? 'ppcp--method-item--disabled' : '' }` }
separatorAndGap={ false }
>
{ isDisabled && (
<div className="ppcp--method-disabled-overlay">
<p className="ppcp--method-disabled-message">
{ disabledMessage }
</p>
</div>
) }
<div className="ppcp--method-inner">
<div className="ppcp--method-title-wrapper">
{ paymentMethod?.icon && (

View file

@ -19,12 +19,14 @@ const PaymentMethodsBlock = ( { paymentMethods = [], onTriggerModal } ) => {
<SettingsBlock className="ppcp--grid ppcp-r-settings-block__payment-methods">
{ paymentMethods
// Remove empty/invalid payment method entries.
.filter( ( m ) => m.id )
.filter( ( m ) => m && m.id )
.map( ( paymentMethod ) => (
<PaymentMethodItemBlock
key={ paymentMethod.id }
paymentMethod={ paymentMethod }
isSelected={ paymentMethod.enabled }
isDisabled={ paymentMethod.isDisabled }
disabledMessage={ paymentMethod.disabledMessage }
onSelect={ ( checked ) =>
handleSelect( paymentMethod.id, checked )
}

View file

@ -0,0 +1,39 @@
import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { scrollAndHighlight } from '../../../../../utils/scrollAndHighlight';
/**
* Component to display a payment method dependency message
*
* @param {Object} props - Component props
* @param {string} props.parentId - ID of the parent payment method
* @param {string} props.parentName - Display name of the parent payment method
* @return {JSX.Element} The formatted message with link
*/
const DependencyMessage = ( { parentId, parentName } ) => {
// Using WordPress createInterpolateElement with proper React elements
return createInterpolateElement(
/* translators: %s: payment method name */
__(
'This payment method requires <methodLink /> to be enabled.',
'woocommerce-paypal-payments'
),
{
methodLink: (
<strong>
<a
href="#"
onClick={ ( e ) => {
e.preventDefault();
scrollAndHighlight( parentId );
} }
>
{ parentName }
</a>
</strong>
),
}
);
};
export default DependencyMessage;

View file

@ -0,0 +1,71 @@
import SettingsCard from '../../../../ReusableComponents/SettingsCard';
import { PaymentMethodsBlock } from '../../../../ReusableComponents/SettingsBlocks';
import usePaymentDependencyState from '../../../../../hooks/usePaymentDependencyState';
import DependencyMessage from './DependencyMessage';
/**
* Renders a payment method card with dependency handling
*
* @param {Object} props - Component props
* @param {string} props.id - Unique identifier for the card
* @param {string} props.title - Title of the payment method card
* @param {string} props.description - Description of the payment method
* @param {string} props.icon - Icon path for the payment method
* @param {Array} props.methods - List of payment methods to display
* @param {Object} props.methodsMap - Map of all payment methods by ID
* @param {Function} props.onTriggerModal - Callback when a method is clicked
* @param {boolean} props.isDisabled - Whether the entire card is disabled
* @param {(string|JSX.Element)} props.disabledMessage - Message to show when disabled
* @return {JSX.Element} The rendered component
*/
const PaymentMethodCard = ( {
id,
title,
description,
icon,
methods,
methodsMap = {},
onTriggerModal,
isDisabled = false,
disabledMessage,
} ) => {
const dependencyState = usePaymentDependencyState( methods, methodsMap );
return (
<SettingsCard
id={ id }
title={ title }
description={ description }
icon={ icon }
contentContainer={ false }
>
<PaymentMethodsBlock
paymentMethods={ methods.map( ( method ) => {
const dependency = dependencyState[ method.id ];
const dependencyMessage = dependency ? (
<DependencyMessage
parentId={ dependency.parentId }
parentName={ dependency.parentName }
/>
) : null;
return {
...method,
isDisabled:
method.isDisabled ||
isDisabled ||
Boolean( dependency?.isDisabled ),
disabledMessage:
method.disabledMessage ||
dependencyMessage ||
disabledMessage,
};
} ) }
onTriggerModal={ onTriggerModal }
/>
</SettingsCard>
);
};
export default PaymentMethodCard;

View file

@ -1,17 +1,23 @@
import { __ } from '@wordpress/i18n';
import { useCallback } from '@wordpress/element';
import SettingsCard from '../../../ReusableComponents/SettingsCard';
import { PaymentMethodsBlock } from '../../../ReusableComponents/SettingsBlocks';
import { CommonHooks, OnboardingHooks, PaymentHooks } from '../../../../data';
import { useActiveModal } from '../../../../data/common/hooks';
import Modal from '../Components/Payment/Modal';
import PaymentMethodCard from '../Components/Payment/PaymentMethodCard';
const TabPaymentMethods = () => {
const methods = PaymentHooks.usePaymentMethods();
const { setPersistent, changePaymentSettings } = PaymentHooks.useStore();
const store = PaymentHooks.useStore();
const { setPersistent, changePaymentSettings } = store;
const { activeModal, setActiveModal } = useActiveModal();
// Get all methods as a map for dependency checking
const methodsMap = {};
methods.all.forEach( ( method ) => {
methodsMap[ method.id ] = method;
} );
const getActiveMethod = () => {
if ( ! activeModal ) {
return null;
@ -60,6 +66,7 @@ const TabPaymentMethods = () => {
icon="icon-checkout-standard.svg"
methods={ methods.paypal }
onTriggerModal={ setActiveModal }
methodsMap={ methodsMap }
/>
{ merchant.isBusinessSeller && canUseCardPayments && (
<PaymentMethodCard
@ -75,6 +82,7 @@ const TabPaymentMethods = () => {
icon="icon-checkout-online-methods.svg"
methods={ methods.cardPayment }
onTriggerModal={ setActiveModal }
methodsMap={ methodsMap }
/>
) }
<PaymentMethodCard
@ -90,6 +98,7 @@ const TabPaymentMethods = () => {
icon="icon-checkout-alternative-methods.svg"
methods={ methods.apm }
onTriggerModal={ setActiveModal }
methodsMap={ methodsMap }
/>
{ activeModal && (
@ -104,25 +113,3 @@ const TabPaymentMethods = () => {
};
export default TabPaymentMethods;
const PaymentMethodCard = ( {
id,
title,
description,
icon,
methods,
onTriggerModal,
} ) => (
<SettingsCard
id={ id }
title={ title }
description={ description }
icon={ icon }
contentContainer={ false }
>
<PaymentMethodsBlock
paymentMethods={ methods }
onTriggerModal={ onTriggerModal }
/>
</SettingsCard>
);

View file

@ -7,6 +7,8 @@
export default {
// Transient data.
SET_TRANSIENT: 'PAYMENT:SET_TRANSIENT',
SET_DISABLED_BY_DEPENDENCY: 'PAYMENT:SET_DISABLED_BY_DEPENDENCY',
RESTORE_DEPENDENCY_STATE: 'PAYMENT:RESTORE_DEPENDENCY_STATE',
// Persistent data.
SET_PERSISTENT: 'PAYMENT:SET_PERSISTENT',

View file

@ -7,6 +7,7 @@ import * as actions from './actions';
import * as hooks from './hooks';
import * as resolvers from './resolvers';
import { initTodoSync } from '../sync/todo-state-sync';
import { initPaymentDependencySync } from '../sync/payment-methods-sync';
/**
* Initializes and registers the settings store with WordPress data layer.
@ -24,9 +25,12 @@ export const initStore = () => {
register( store );
// Initialize todo sync after store registration. Potentially should be moved elsewhere.
// Initialize todo sync after store registration.
initTodoSync();
// Initialize payment method dependency sync.
initPaymentDependencySync();
return Boolean( wp.data.select( STORE_NAME ) );
};

View file

@ -88,6 +88,56 @@ const reducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) =>
changePersistent( state, payload.data ),
[ ACTION_TYPES.SET_DISABLED_BY_DEPENDENCY ]: ( state, payload ) => {
const { methodId } = payload;
const method = state.data[ methodId ];
if ( ! method ) {
return state;
}
// Create a new state with the method disabled due to dependency
const updatedData = {
...state.data,
[ methodId ]: {
...method,
enabled: false,
_disabledByDependency: true,
_originalState: method.enabled,
},
};
return {
...state,
data: updatedData,
};
},
[ ACTION_TYPES.RESTORE_DEPENDENCY_STATE ]: ( state, payload ) => {
const { methodId } = payload;
const method = state.data[ methodId ];
if ( ! method || ! method._disabledByDependency ) {
return state;
}
// Restore the method to its original state
const updatedData = {
...state.data,
[ methodId ]: {
...method,
enabled: method._originalState === true,
_disabledByDependency: false,
_originalState: undefined,
},
};
return {
...state,
data: updatedData,
};
},
} );
export default reducer;

View file

@ -0,0 +1,126 @@
import { subscribe, select } from '@wordpress/data';
// Store name
const PAYMENT_STORE = 'wc/paypal/payment';
// Track original states of dependent methods
const originalStates = {};
/**
* Initialize payment method dependency synchronization
*/
export const initPaymentDependencySync = () => {
let previousPaymentState = null;
let isProcessing = false;
const unsubscribe = subscribe( () => {
if ( isProcessing ) {
return;
}
isProcessing = true;
try {
const paymentHooks = select( PAYMENT_STORE );
if ( ! paymentHooks ) {
isProcessing = false;
return;
}
const methods = paymentHooks.persistentData();
if ( ! methods ) {
isProcessing = false;
return;
}
if ( ! previousPaymentState ) {
previousPaymentState = { ...methods };
isProcessing = false;
return;
}
const changedMethods = Object.keys( methods )
.filter(
( key ) =>
key !== '__meta' &&
methods[ key ] &&
previousPaymentState[ key ]
)
.filter(
( methodId ) =>
methods[ methodId ].enabled !==
previousPaymentState[ methodId ].enabled
);
if ( changedMethods.length > 0 ) {
changedMethods.forEach( ( changedId ) => {
const isNowEnabled = methods[ changedId ].enabled;
const dependents = Object.entries( methods )
.filter(
( [ key, method ] ) =>
key !== '__meta' &&
method &&
method.depends_on &&
method.depends_on.includes( changedId )
)
.map( ( [ key ] ) => key );
if ( dependents.length > 0 ) {
if ( ! isNowEnabled ) {
handleDisableDependents( dependents, methods );
} else {
handleRestoreDependents( dependents, methods );
}
}
} );
}
previousPaymentState = { ...methods };
} catch ( error ) {
// Keep error handling without the console.error
} finally {
isProcessing = false;
}
} );
return unsubscribe;
};
const handleDisableDependents = ( dependentIds, methods ) => {
dependentIds.forEach( ( methodId ) => {
if ( methods[ methodId ] ) {
if ( ! ( methodId in originalStates ) ) {
originalStates[ methodId ] = methods[ methodId ].enabled;
}
methods[ methodId ].enabled = false;
methods[ methodId ].isDisabled = true;
}
} );
};
const handleRestoreDependents = ( dependentIds, methods ) => {
dependentIds.forEach( ( methodId ) => {
if (
methods[ methodId ] &&
methodId in originalStates &&
checkAllDependenciesSatisfied( methodId, methods )
) {
methods[ methodId ].enabled = originalStates[ methodId ];
methods[ methodId ].isDisabled = false;
delete originalStates[ methodId ];
}
} );
};
const checkAllDependenciesSatisfied = ( methodId, methods ) => {
const method = methods[ methodId ];
if ( ! method || ! method.depends_on ) {
return true;
}
return ! method.depends_on.some( ( parentId ) => {
const parent = methods[ parentId ];
return ! parent || parent.enabled === false;
} );
};

View file

@ -0,0 +1,81 @@
import { useSelect } from '@wordpress/data';
/**
* Gets the display name for a parent payment method
*
* @param {string} parentId - ID of the parent payment method
* @param {Object} methodsMap - Map of all payment methods by ID
* @return {string} The display name to use for the parent method
*/
const getParentMethodName = ( parentId, methodsMap ) => {
const parentMethod = methodsMap[ parentId ];
return parentMethod
? parentMethod.itemTitle || parentMethod.title || ''
: '';
};
/**
* Finds disabled parent dependencies for a method
*
* @param {Object} method - The payment method to check
* @param {Object} methodsMap - Map of all payment methods by ID
* @return {Array} List of disabled parent IDs, empty if none
*/
const findDisabledParents = ( method, methodsMap ) => {
if ( ! method.depends_on?.length && ! method._disabledByDependency ) {
return [];
}
const parents = method.depends_on || [];
return parents.filter( ( parentId ) => {
const parent = methodsMap[ parentId ];
return parent && ! parent.enabled;
} );
};
/**
* Custom hook to handle payment method dependencies
*
* @param {Array} methods - List of payment methods
* @param {Object} methodsMap - Map of payment methods by ID
* @return {Object} Dependency state object with methods that should be disabled
*/
const usePaymentDependencyState = ( methods, methodsMap ) => {
return useSelect(
( select ) => {
const paymentStore = select( 'wc/paypal/payment' );
if ( ! paymentStore ) {
return {};
}
const result = {};
methods.forEach( ( method ) => {
const disabledParents = findDisabledParents(
method,
methodsMap
);
if ( disabledParents.length > 0 ) {
const parentId = disabledParents[ 0 ];
const parentName = getParentMethodName(
parentId,
methodsMap
);
result[ method.id ] = {
isDisabled: true,
parentId,
parentName,
};
}
} );
return result;
},
[ methods, methodsMap ]
);
};
export default usePaymentDependencyState;

View file

@ -0,0 +1,49 @@
/**
* Scroll to a specific element and highlight it
*
* @param {string} elementId - ID of the element to scroll to
* @param {boolean} [highlight=true] - Whether to highlight the element
* @return {Promise} - Resolves when scroll and highlight are complete
*/
export const scrollAndHighlight = ( elementId, highlight = true ) => {
return new Promise( ( resolve ) => {
const scrollTarget = document.getElementById( elementId );
if ( scrollTarget ) {
const navContainer = document.querySelector(
'.ppcp-r-navigation-container'
);
const navHeight = navContainer ? navContainer.offsetHeight : 0;
// Get the current scroll position and element's position relative to viewport
const rect = scrollTarget.getBoundingClientRect();
// Calculate the final position with offset
const scrollPosition =
rect.top + window.scrollY - ( navHeight + 55 );
window.scrollTo( {
top: scrollPosition,
behavior: 'smooth',
} );
// Add highlight if requested
if ( highlight ) {
scrollTarget.classList.add( 'ppcp-highlight' );
// Remove highlight after animation
setTimeout( () => {
scrollTarget.classList.remove( 'ppcp-highlight' );
}, 2000 );
}
// Resolve after scroll animation
setTimeout( resolve, 300 );
} else {
console.error(
`Failed to scroll: Element with ID "${ elementId }" not found`
);
resolve();
}
} );
};

View file

@ -7,6 +7,8 @@ export const TAB_IDS = {
PAY_LATER_MESSAGING: 'tab-panel-0-pay-later-messaging',
};
import { scrollAndHighlight } from './scrollAndHighlight';
/**
* Select a tab by simulating a click event and scroll to specified element,
* accounting for navigation container height
@ -23,40 +25,8 @@ export const selectTab = ( tabId, scrollToId ) => {
if ( tab ) {
tab.click();
setTimeout( () => {
const scrollTarget = scrollToId
? document.getElementById( scrollToId )
: document.getElementById( 'ppcp-settings-container' );
if ( scrollTarget ) {
const navContainer = document.querySelector(
'.ppcp-r-navigation-container'
);
const navHeight = navContainer
? navContainer.offsetHeight
: 0;
// Get the current scroll position and element's position relative to viewport
const rect = scrollTarget.getBoundingClientRect();
// Calculate the final position with offset
const scrollPosition =
rect.top + window.scrollY - ( navHeight + 55 );
window.scrollTo( {
top: scrollPosition,
behavior: 'smooth',
} );
// Resolve after scroll animation
setTimeout( resolve, 300 );
} else {
console.error(
`Failed to scroll: Element with ID "${
scrollToId || 'ppcp-settings-container'
}" not found`
);
resolve();
}
const targetId = scrollToId || 'ppcp-settings-container';
scrollAndHighlight( targetId, false ).then( resolve );
}, 100 );
} else {
console.error(

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Settings;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Settings\Data\Definition\PaymentMethodsDependenciesDefinition;
use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings;
use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile;
use WooCommerce\PayPalCommerce\Settings\Data\PaymentSettings;
@ -46,7 +47,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
use WooCommerce\PayPalCommerce\WcGateway\Helper\ConnectionState;
return array(
'settings.url' => static function ( ContainerInterface $container ) : string {
'settings.url' => static function ( ContainerInterface $container ) : string {
/**
* The path cannot be false.
*
@ -57,7 +58,7 @@ return array(
dirname( realpath( __FILE__ ), 3 ) . '/woocommerce-paypal-payments.php'
);
},
'settings.data.onboarding' => static function ( ContainerInterface $container ) : OnboardingProfile {
'settings.data.onboarding' => static function ( ContainerInterface $container ) : OnboardingProfile {
$can_use_casual_selling = $container->get( 'settings.casual-selling.eligible' );
$can_use_vaulting = $container->has( 'save-payment-methods.eligible' ) && $container->get( 'save-payment-methods.eligible' );
$can_use_card_payments = $container->has( 'card-fields.eligible' ) && $container->get( 'card-fields.eligible' );
@ -77,27 +78,27 @@ return array(
$can_use_subscriptions
);
},
'settings.data.general' => static function ( ContainerInterface $container ) : GeneralSettings {
'settings.data.general' => static function ( ContainerInterface $container ) : GeneralSettings {
return new GeneralSettings(
$container->get( 'api.shop.country' ),
$container->get( 'api.shop.currency.getter' )->get(),
$container->get( 'wcgateway.is-send-only-country' )
);
},
'settings.data.styling' => static function ( ContainerInterface $container ) : StylingSettings {
'settings.data.styling' => static function ( ContainerInterface $container ) : StylingSettings {
return new StylingSettings(
$container->get( 'settings.service.sanitizer' )
);
},
'settings.data.payment' => static function ( ContainerInterface $container ) : PaymentSettings {
'settings.data.payment' => static function ( ContainerInterface $container ) : PaymentSettings {
return new PaymentSettings();
},
'settings.data.settings' => static function ( ContainerInterface $container ) : SettingsModel {
'settings.data.settings' => static function ( ContainerInterface $container ) : SettingsModel {
return new SettingsModel(
$container->get( 'settings.service.sanitizer' )
);
},
'settings.data.paylater-messaging' => static function ( ContainerInterface $container ) : array {
'settings.data.paylater-messaging' => static function ( ContainerInterface $container ) : array {
// TODO: Create an AbstractDataModel wrapper for this configuration!
$config_factors = $container->get( 'paylater-configurator.factory.config' );
@ -121,7 +122,7 @@ return array(
* (onboarding/connected) and connection-aware environment checks.
* This is the preferred solution to check environment and connection state.
*/
'settings.connection-state' => static function ( ContainerInterface $container ) : ConnectionState {
'settings.connection-state' => static function ( ContainerInterface $container ) : ConnectionState {
$data = $container->get( 'settings.data.general' );
assert( $data instanceof GeneralSettings );
@ -130,7 +131,7 @@ return array(
return new ConnectionState( $is_connected, $environment );
},
'settings.environment' => static function ( ContainerInterface $container ) : Environment {
'settings.environment' => static function ( ContainerInterface $container ) : Environment {
// We should remove this service in favor of directly using `settings.connection-state`.
$state = $container->get( 'settings.connection-state' );
assert( $state instanceof ConnectionState );
@ -140,7 +141,7 @@ return array(
/**
* Checks if valid merchant connection details are stored in the DB.
*/
'settings.flag.is-connected' => static function ( ContainerInterface $container ) : bool {
'settings.flag.is-connected' => static function ( ContainerInterface $container ) : bool {
/*
* This service only resolves the connection status once per request.
* We should remove this service in favor of directly using `settings.connection-state`.
@ -153,7 +154,7 @@ return array(
/**
* Checks if the merchant is connected to a sandbox environment.
*/
'settings.flag.is-sandbox' => static function ( ContainerInterface $container ) : bool {
'settings.flag.is-sandbox' => static function ( ContainerInterface $container ) : bool {
/*
* This service only resolves the sandbox flag once per request.
* We should remove this service in favor of directly using `settings.connection-state`.
@ -163,61 +164,61 @@ return array(
return $state->is_sandbox();
},
'settings.rest.onboarding' => static function ( ContainerInterface $container ) : OnboardingRestEndpoint {
'settings.rest.onboarding' => static function ( ContainerInterface $container ) : OnboardingRestEndpoint {
return new OnboardingRestEndpoint( $container->get( 'settings.data.onboarding' ) );
},
'settings.rest.common' => static function ( ContainerInterface $container ) : CommonRestEndpoint {
'settings.rest.common' => static function ( ContainerInterface $container ) : CommonRestEndpoint {
return new CommonRestEndpoint( $container->get( 'settings.data.general' ) );
},
'settings.rest.payment' => static function ( ContainerInterface $container ) : PaymentRestEndpoint {
'settings.rest.payment' => static function ( ContainerInterface $container ) : PaymentRestEndpoint {
return new PaymentRestEndpoint(
$container->get( 'settings.data.payment' ),
$container->get( 'settings.data.definition.methods' )
);
},
'settings.rest.styling' => static function ( ContainerInterface $container ) : StylingRestEndpoint {
'settings.rest.styling' => static function ( ContainerInterface $container ) : StylingRestEndpoint {
return new StylingRestEndpoint(
$container->get( 'settings.data.styling' ),
$container->get( 'settings.service.sanitizer' )
);
},
'settings.rest.refresh_feature_status' => static function ( ContainerInterface $container ) : RefreshFeatureStatusEndpoint {
'settings.rest.refresh_feature_status' => static function ( ContainerInterface $container ) : RefreshFeatureStatusEndpoint {
return new RefreshFeatureStatusEndpoint(
$container->get( 'wcgateway.settings' ),
new Cache( 'ppcp-timeout' ),
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'settings.rest.authentication' => static function ( ContainerInterface $container ) : AuthenticationRestEndpoint {
'settings.rest.authentication' => static function ( ContainerInterface $container ) : AuthenticationRestEndpoint {
return new AuthenticationRestEndpoint(
$container->get( 'settings.service.authentication_manager' ),
$container->get( 'settings.service.data-manager' )
);
},
'settings.rest.login_link' => static function ( ContainerInterface $container ) : LoginLinkRestEndpoint {
'settings.rest.login_link' => static function ( ContainerInterface $container ) : LoginLinkRestEndpoint {
return new LoginLinkRestEndpoint(
$container->get( 'settings.service.connection-url-generator' ),
);
},
'settings.rest.webhooks' => static function ( ContainerInterface $container ) : WebhookSettingsEndpoint {
'settings.rest.webhooks' => static function ( ContainerInterface $container ) : WebhookSettingsEndpoint {
return new WebhookSettingsEndpoint(
$container->get( 'api.endpoint.webhook' ),
$container->get( 'webhook.registrar' ),
$container->get( 'webhook.status.simulation' )
);
},
'settings.rest.pay_later_messaging' => static function ( ContainerInterface $container ) : PayLaterMessagingEndpoint {
'settings.rest.pay_later_messaging' => static function ( ContainerInterface $container ) : PayLaterMessagingEndpoint {
return new PayLaterMessagingEndpoint(
$container->get( 'wcgateway.settings' ),
$container->get( 'paylater-configurator.endpoint.save-config' )
);
},
'settings.rest.settings' => static function ( ContainerInterface $container ) : SettingsRestEndpoint {
'settings.rest.settings' => static function ( ContainerInterface $container ) : SettingsRestEndpoint {
return new SettingsRestEndpoint(
$container->get( 'settings.data.settings' )
);
},
'settings.casual-selling.supported-countries' => static function ( ContainerInterface $container ) : array {
'settings.casual-selling.supported-countries' => static function ( ContainerInterface $container ) : array {
return array(
'AR',
'AU',
@ -267,13 +268,13 @@ return array(
'VN',
);
},
'settings.casual-selling.eligible' => static function ( ContainerInterface $container ) : bool {
'settings.casual-selling.eligible' => static function ( ContainerInterface $container ) : bool {
$country = $container->get( 'api.shop.country' );
$eligible_countries = $container->get( 'settings.casual-selling.supported-countries' );
return in_array( $country, $eligible_countries, true );
},
'settings.handler.connection-listener' => static function ( ContainerInterface $container ) : ConnectionListener {
'settings.handler.connection-listener' => static function ( ContainerInterface $container ) : ConnectionListener {
$page_id = $container->has( 'wcgateway.current-ppcp-settings-page-id' ) ? $container->get( 'wcgateway.current-ppcp-settings-page-id' ) : '';
return new ConnectionListener(
@ -284,16 +285,16 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'settings.service.signup-link-cache' => static function ( ContainerInterface $container ) : Cache {
'settings.service.signup-link-cache' => static function ( ContainerInterface $container ) : Cache {
return new Cache( 'ppcp-paypal-signup-link' );
},
'settings.service.onboarding-url-manager' => static function ( ContainerInterface $container ) : OnboardingUrlManager {
'settings.service.onboarding-url-manager' => static function ( ContainerInterface $container ) : OnboardingUrlManager {
return new OnboardingUrlManager(
$container->get( 'settings.service.signup-link-cache' ),
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'settings.service.connection-url-generator' => static function ( ContainerInterface $container ) : ConnectionUrlGenerator {
'settings.service.connection-url-generator' => static function ( ContainerInterface $container ) : ConnectionUrlGenerator {
return new ConnectionUrlGenerator(
$container->get( 'api.env.endpoint.partner-referrals' ),
$container->get( 'api.repository.partner-referrals-data' ),
@ -301,7 +302,7 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'settings.service.authentication_manager' => static function ( ContainerInterface $container ) : AuthenticationManager {
'settings.service.authentication_manager' => static function ( ContainerInterface $container ) : AuthenticationManager {
return new AuthenticationManager(
$container->get( 'settings.data.general' ),
$container->get( 'api.env.paypal-host' ),
@ -312,10 +313,10 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'settings.service.sanitizer' => static function ( ContainerInterface $container ) : DataSanitizer {
'settings.service.sanitizer' => static function ( ContainerInterface $container ) : DataSanitizer {
return new DataSanitizer();
},
'settings.service.data-manager' => static function ( ContainerInterface $container ) : SettingsDataManager {
'settings.service.data-manager' => static function ( ContainerInterface $container ) : SettingsDataManager {
return new SettingsDataManager(
$container->get( 'settings.data.definition.methods' ),
$container->get( 'settings.data.onboarding' ),
@ -327,7 +328,7 @@ return array(
$container->get( 'settings.data.todos' ),
);
},
'settings.ajax.switch_ui' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint {
'settings.ajax.switch_ui' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint {
return new SwitchSettingsUiEndpoint(
$container->get( 'woocommerce.logger.woocommerce' ),
$container->get( 'button.request-data' ),
@ -335,7 +336,7 @@ return array(
$container->get( 'api.merchant_id' ) !== ''
);
},
'settings.rest.todos' => static function ( ContainerInterface $container ) : TodosRestEndpoint {
'settings.rest.todos' => static function ( ContainerInterface $container ) : TodosRestEndpoint {
return new TodosRestEndpoint(
$container->get( 'settings.data.todos' ),
$container->get( 'settings.data.definition.todos' ),
@ -343,22 +344,26 @@ return array(
$container->get( 'settings.service.todos_sorting' )
);
},
'settings.data.todos' => static function ( ContainerInterface $container ) : TodosModel {
'settings.data.todos' => static function ( ContainerInterface $container ) : TodosModel {
return new TodosModel();
},
'settings.data.definition.todos' => static function ( ContainerInterface $container ) : TodosDefinition {
'settings.data.definition.todos' => static function ( ContainerInterface $container ) : TodosDefinition {
return new TodosDefinition(
$container->get( 'settings.service.todos_eligibilities' ),
$container->get( 'settings.data.general' ),
$container->get( 'wc-subscriptions.helper' )
);
},
'settings.data.definition.methods' => static function ( ContainerInterface $container ) : PaymentMethodsDefinition {
'settings.data.definition.methods' => static function ( ContainerInterface $container ) : PaymentMethodsDefinition {
return new PaymentMethodsDefinition(
$container->get( 'settings.data.payment' ),
$container->get( 'settings.data.definition.method_dependencies' )
);
},
'settings.service.pay_later_status' => static function ( ContainerInterface $container ) : array {
'settings.data.definition.method_dependencies' => static function ( ContainerInterface $container ) : PaymentMethodsDependenciesDefinition {
return new PaymentMethodsDependenciesDefinition();
},
'settings.service.pay_later_status' => static function ( ContainerInterface $container ) : array {
$pay_later_endpoint = $container->get( 'settings.rest.pay_later_messaging' );
$pay_later_settings = $pay_later_endpoint->get_details()->get_data();
@ -379,7 +384,7 @@ return array(
'is_enabled_for_any_location' => $is_pay_later_messaging_enabled_for_any_location,
);
},
'settings.service.button_locations' => static function ( ContainerInterface $container ) : array {
'settings.service.button_locations' => static function ( ContainerInterface $container ) : array {
$styling_endpoint = $container->get( 'settings.rest.styling' );
$styling_data = $styling_endpoint->get_details()->get_data()['data'];
@ -389,7 +394,7 @@ return array(
'product_enabled' => $styling_data['product']->enabled ?? false,
);
},
'settings.service.gateways_status' => static function ( ContainerInterface $container ) : array {
'settings.service.gateways_status' => static function ( ContainerInterface $container ) : array {
$payment_endpoint = $container->get( 'settings.rest.payment' );
$settings = $payment_endpoint->get_details()->get_data();
@ -400,7 +405,7 @@ return array(
'card-button' => $settings['data']['ppcp-card-button-gateway']['enabled'] ?? false,
);
},
'settings.service.merchant_capabilities' => static function ( ContainerInterface $container ) : array {
'settings.service.merchant_capabilities' => static function ( ContainerInterface $container ) : array {
$features = apply_filters(
'woocommerce_paypal_payments_rest_common_merchant_features',
array()
@ -416,7 +421,7 @@ return array(
);
},
'settings.service.todos_eligibilities' => static function ( ContainerInterface $container ) : TodosEligibilityService {
'settings.service.todos_eligibilities' => static function ( ContainerInterface $container ) : TodosEligibilityService {
$pay_later_service = $container->get( 'settings.service.pay_later_status' );
$pay_later_statuses = $pay_later_service['statuses'];
$is_pay_later_messaging_enabled_for_any_location = $pay_later_service['is_enabled_for_any_location'];
@ -473,7 +478,7 @@ return array(
$container->get( 'googlepay.eligible' ) && $capabilities['google_pay'] && ! $gateways['google_pay'],
);
},
'settings.service.todos_sorting' => static function ( ContainerInterface $container ) : TodosSortingAndFilteringService {
'settings.service.todos_sorting' => static function ( ContainerInterface $container ) : TodosSortingAndFilteringService {
return new TodosSortingAndFilteringService(
$container->get( 'settings.data.todos' )
);

View file

@ -1,6 +1,6 @@
<?php
/**
* PayPal Commerce Todos Definitions
* Payment Methods Definitions
*
* @package WooCommerce\PayPalCommerce\Settings\Data\Definition
*/
@ -40,6 +40,13 @@ class PaymentMethodsDefinition {
*/
private PaymentSettings $settings;
/**
* Payment method dependencies definition.
*
* @var PaymentMethodsDependenciesDefinition
*/
private PaymentMethodsDependenciesDefinition $dependencies_definition;
/**
* List of WooCommerce payment gateways.
*
@ -50,10 +57,15 @@ class PaymentMethodsDefinition {
/**
* Constructor.
*
* @param PaymentSettings $settings Payment methods data model.
* @param PaymentSettings $settings Payment methods data model.
* @param PaymentMethodsDependenciesDefinition $dependencies_definition Payment dependencies definition.
*/
public function __construct( PaymentSettings $settings ) {
$this->settings = $settings;
public function __construct(
PaymentSettings $settings,
PaymentMethodsDependenciesDefinition $dependencies_definition
) {
$this->settings = $settings;
$this->dependencies_definition = $dependencies_definition;
}
/**
@ -73,15 +85,30 @@ class PaymentMethodsDefinition {
$result = array();
foreach ( $all_methods as $method ) {
$result[ $method['id'] ] = $this->build_method_definition(
$method['id'],
$method_id = $method['id'];
// Add dependency info if applicable.
$depends_on = $this->dependencies_definition->get_parent_methods( $method_id );
if ( ! empty( $depends_on ) ) {
$method['depends_on'] = $depends_on;
}
$result[ $method_id ] = $this->build_method_definition(
$method_id,
$method['title'],
$method['description'],
$method['icon'],
$method['fields'] ?? array()
$method['fields'] ?? array(),
$depends_on
);
}
// Add dependency maps to metadata.
$result['__meta'] = array(
'dependencies' => $this->dependencies_definition->get_dependencies(),
'dependents' => $this->dependencies_definition->get_dependents_map(),
);
return $result;
}
@ -95,14 +122,15 @@ class PaymentMethodsDefinition {
* @param string $icon Admin-side icon of the payment method.
* @param array|false $fields Optional. Additional fields to display in the edit modal.
* Setting this to false omits all fields.
* @param array $depends_on Optional. IDs of payment methods that this depends on.
* @return array Payment method definition.
*/
private function build_method_definition(
string $gateway_id,
string $title,
string $description,
string $icon,
$fields = array()
string $icon, $fields = array(),
array $depends_on = array()
) : array {
$gateway = $this->wc_gateways[ $gateway_id ] ?? null;
@ -119,6 +147,11 @@ class PaymentMethodsDefinition {
'itemDescription' => $description,
);
// Add dependency information if provided - ensure it's included directly in the config.
if ( ! empty( $depends_on ) ) {
$config['depends_on'] = $depends_on;
}
if ( is_array( $fields ) ) {
$config['fields'] = array_merge(
array(

View file

@ -0,0 +1,111 @@
<?php
/**
* Payment Methods Dependencies Definition
*
* @package WooCommerce\PayPalCommerce\Settings\Data\Definition
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Data\Definition;
use WooCommerce\PayPalCommerce\Applepay\ApplePayGateway;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Googlepay\GooglePayGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\BancontactGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\BlikGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\EPSGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\IDealGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\MultibancoGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\MyBankGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\P24Gateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\TrustlyGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXO;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PayUponInvoiceGateway;
/**
* Class PaymentMethodsDependenciesDefinition
*
* Defines dependency relationships between payment methods.
*/
class PaymentMethodsDependenciesDefinition {
/**
* Get all payment method dependencies
*
* Maps dependent method ID => array of parent method IDs.
* A dependent method is disabled if ANY of its required parents is disabled.
*
* @return array The dependency relationships between payment methods
*/
public function get_dependencies(): array {
$dependencies = array(
CardButtonGateway::ID => array( PayPalGateway::ID ),
CreditCardGateway::ID => array( PayPalGateway::ID ),
AxoGateway::ID => array( PayPalGateway::ID, CreditCardGateway::ID ),
ApplePayGateway::ID => array( PayPalGateway::ID, CreditCardGateway::ID ),
GooglePayGateway::ID => array( PayPalGateway::ID, CreditCardGateway::ID ),
BancontactGateway::ID => array( PayPalGateway::ID ),
BlikGateway::ID => array( PayPalGateway::ID ),
EPSGateway::ID => array( PayPalGateway::ID ),
IDealGateway::ID => array( PayPalGateway::ID ),
MultibancoGateway::ID => array( PayPalGateway::ID ),
MyBankGateway::ID => array( PayPalGateway::ID ),
P24Gateway::ID => array( PayPalGateway::ID ),
TrustlyGateway::ID => array( PayPalGateway::ID ),
PayUponInvoiceGateway::ID => array( PayPalGateway::ID ),
OXXO::ID => array( PayPalGateway::ID ),
'venmo' => array( PayPalGateway::ID ),
'pay-later' => array( PayPalGateway::ID ),
);
return apply_filters(
'woocommerce_paypal_payments_payment_method_dependencies',
$dependencies
);
}
/**
* Create a mapping from parent methods to their dependent methods
*
* @return array Parent-to-child dependency map
*/
public function get_dependents_map(): array {
$result = array();
$dependencies = $this->get_dependencies();
foreach ( $dependencies as $child_id => $parent_ids ) {
foreach ( $parent_ids as $parent_id ) {
if ( ! isset( $result[ $parent_id ] ) ) {
$result[ $parent_id ] = array();
}
$result[ $parent_id ][] = $child_id;
}
}
return $result;
}
/**
* Get all parent methods that a method depends on
*
* @param string $method_id Method ID to check.
* @return array Array of parent method IDs
*/
public function get_parent_methods( string $method_id ): array {
return $this->get_dependencies()[ $method_id ] ?? array();
}
/**
* Get methods that depend on a parent method
*
* @param string $parent_id Parent method ID.
* @return array Array of dependent method IDs
*/
public function get_dependent_methods( string $parent_id ): array {
return $this->get_dependents_map()[ $parent_id ] ?? array();
}
}

View file

@ -150,7 +150,17 @@ class PaymentRestEndpoint extends RestEndpoint {
$gateway_settings = array();
$all_methods = $this->gateways();
// First extract __meta if present.
if ( isset( $all_methods['__meta'] ) ) {
$gateway_settings['__meta'] = $all_methods['__meta'];
}
foreach ( $all_methods as $key => $method ) {
// Skip the __meta key as we've already handled it.
if ( $key === '__meta' ) {
continue;
}
$gateway_settings[ $key ] = array(
'id' => $method['id'],
'title' => $method['title'],
@ -164,6 +174,11 @@ class PaymentRestEndpoint extends RestEndpoint {
if ( isset( $method['fields'] ) ) {
$gateway_settings[ $key ]['fields'] = $method['fields'];
}
// Preserve dependency information.
if ( isset( $method['depends_on'] ) ) {
$gateway_settings[ $key ]['depends_on'] = $method['depends_on'];
}
}
$gateway_settings['paypalShowLogo'] = $this->settings->get_paypal_show_logo();