Merge branch 'trunk'

# Conflicts:
#	modules/ppcp-button/src/Helper/ThreeDSecure.php
This commit is contained in:
Philipp Stracker 2025-04-23 15:00:00 +02:00
commit ebcf91461f
No known key found for this signature in database
54 changed files with 1197 additions and 448 deletions

View file

@ -1,5 +1,27 @@
*** Changelog *** *** Changelog ***
= 3.0.4 - xxxx-xx-xx =
* Fix - Onboarding screen blank when WooPayments plugin is active #3312
= 3.0.3 - 2025-04-08 =
* Fix - BN code was set before the installation path was initialized #3309
* Fix - Things to do next referenced Apple Pay while in branded-only mode #3308
* Fix - Disabled payment methods were not hidden in reactified WooCommerce Payments settings tab #3290
= 3.0.2 - 2025-04-03 =
* Enhancement - Check the branded-only flag when settings-UI is loaded the first time #3278
* Enhancement - Implement a Cache-Flush API #3276
* Enhancement - Disable the mini-cart location by default #3284
* Enhancement - Remove branded-only flag when uninstalling PayPal Payments #3295
* Fix - Welcome screen lists "all major credit/debit cards, Apple Pay, Google Pay," in branded-only mode #3281
* Fix - Correct heading in onboarding step 4 in branded-only mode #3282
* Fix - Hide the payment methods screen for personal user in branded-only mode #3286
* Fix - Enabling Save PayPal does not disable Pay Later messaging #3288
* Fix - Settings UI: Fix Feature button links #3285
* Fix - Create mapping for the 3d_secure_contingency setting #3262
* Fix - Enable Fastlane Watermark by default in new settings UI #3296
* Fix - Payment method screen is referencing credit cards, digital wallets in branded-only mode #3297
= 3.0.1 - 2025-03-26 = = 3.0.1 - 2025-03-26 =
* Enhancement - Include Fastlane meta on homepage #3151 * Enhancement - Include Fastlane meta on homepage #3151
* Enhancement - Include Branded-only plugin configuration for certain installation paths * Enhancement - Include Branded-only plugin configuration for certain installation paths

View file

@ -23,6 +23,7 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken; use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken;
use Psr\Log\LoggerInterface;
/** /**
* Class ApiModule * Class ApiModule
@ -113,23 +114,20 @@ class ApiModule implements ServiceModule, ExtendingModule, ExecutableModule {
'woocommerce_paypal_payments_flush_api_cache', 'woocommerce_paypal_payments_flush_api_cache',
static function () use ( $c ) { static function () use ( $c ) {
$caches = array( $caches = array(
'api.paypal-bearer-cache' => array( 'api.paypal-bearer-cache',
PayPalBearer::CACHE_KEY, 'api.client-credentials-cache',
), 'settings.service.signup-link-cache',
'api.client-credentials-cache' => array(
SdkClientToken::CACHE_KEY,
),
); );
foreach ( $caches as $cache_id => $keys ) { $logger = $c->get( 'woocommerce.logger.woocommerce' );
assert( $logger instanceof LoggerInterface );
$logger->info( 'Flushing API caches...' );
foreach ( $caches as $cache_id ) {
$cache = $c->get( $cache_id ); $cache = $c->get( $cache_id );
assert( $cache instanceof Cache ); assert( $cache instanceof Cache );
foreach ( $keys as $key ) { $cache->flush();
if ( $cache->has( $key ) ) {
$cache->delete( $key );
}
}
} }
} }
); );

View file

@ -5,7 +5,7 @@
* @package WooCommerce\PayPalCommerce\ApiClient\Helper * @package WooCommerce\PayPalCommerce\ApiClient\Helper
*/ */
declare( strict_types=1 ); declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\ApiClient\Helper; namespace WooCommerce\PayPalCommerce\ApiClient\Helper;
@ -48,8 +48,9 @@ class Cache {
* *
* @return bool * @return bool
*/ */
public function has( string $key ): bool { public function has( string $key ) : bool {
$value = $this->get( $key ); $value = $this->get( $key );
return false !== $value; return false !== $value;
} }
@ -58,20 +59,47 @@ class Cache {
* *
* @param string $key The key. * @param string $key The key.
*/ */
public function delete( string $key ): void { public function delete( string $key ) : void {
delete_transient( $this->prefix . $key ); delete_transient( $this->prefix . $key );
} }
/** /**
* Caches a value. * Caches a value.
* *
* @param string $key The key under which the value should be cached. * @param string $key The key under which the value should be cached.
* @param mixed $value The value to cache. * @param mixed $value The value to cache.
* @param int $expiration Time until expiration in seconds. * @param int $expiration Time until expiration in seconds.
* *
* @return bool * @return bool
*/ */
public function set( string $key, $value, int $expiration = 0 ): bool { public function set( string $key, $value, int $expiration = 0 ) : bool {
return (bool) set_transient( $this->prefix . $key, $value, $expiration ); return (bool) set_transient( $this->prefix . $key, $value, $expiration );
} }
/**
* Flushes all items of the current "cache group", i.e., items that use the defined prefix.
*
* @return void
*/
public function flush() : void {
global $wpdb;
// Get a list of all transients with the relevant "group prefix" from the DB.
$transients = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s",
$wpdb->esc_like( '_transient_' . $this->prefix ) . '%'
)
);
/**
* Delete each cache item individually to ensure WP can fire all relevant
* actions, perform checks and other cleanup tasks and ensures eventually
* object cache systems, like Redis, are kept in-sync with the DB.
*/
foreach ( $transients as $transient ) {
$key = str_replace( '_transient_' . $this->prefix, '', $transient );
$this->delete( $key );
}
}
} }

View file

@ -101,7 +101,7 @@ $fast-transition-duration: 0.5s;
} }
// 3. Express Payment Block // 3. Express Payment Block
.wp-block-woocommerce-checkout-express-payment-block { .wc-block-components-express-payment--checkout, .wp-block-woocommerce-checkout-express-payment-block {
transition: opacity $transition-duration ease-in, transition: opacity $transition-duration ease-in,
scale $transition-duration ease-in, scale $transition-duration ease-in,
display $transition-duration ease-in; display $transition-duration ease-in;

View file

@ -10,7 +10,7 @@ import { STORE_NAME } from '../stores/axoStore';
*/ */
export const setupAuthenticationClassToggle = () => { export const setupAuthenticationClassToggle = () => {
const targetSelector = const targetSelector =
'.wp-block-woocommerce-checkout-express-payment-block'; '.wc-block-components-express-payment--checkout, .wp-block-woocommerce-checkout-express-payment-block';
const authClass = 'wc-block-axo-is-authenticated'; const authClass = 'wc-block-axo-is-authenticated';
const updateAuthenticationClass = () => { const updateAuthenticationClass = () => {

View file

@ -181,8 +181,6 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
add_action( add_action(
'wp_loaded', 'wp_loaded',
function () use ( $c ) { function () use ( $c ) {
$module = $this;
$this->session_handler = $c->get( 'session.handler' ); $this->session_handler = $c->get( 'session.handler' );
$settings = $c->get( 'wcgateway.settings' ); $settings = $c->get( 'wcgateway.settings' );
@ -208,12 +206,12 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
// Enqueue frontend scripts. // Enqueue frontend scripts.
add_action( add_action(
'wp_enqueue_scripts', 'wp_enqueue_scripts',
static function () use ( $c, $manager, $module ) { function () use ( $c, $manager ) {
$smart_button = $c->get( 'button.smart-button' ); $smart_button = $c->get( 'button.smart-button' );
assert( $smart_button instanceof SmartButtonInterface ); assert( $smart_button instanceof SmartButtonInterface );
if ( $module->should_render_fastlane( $c ) && $smart_button->should_load_ppcp_script() ) { if ( $this->should_render_fastlane( $c ) && $smart_button->should_load_ppcp_script() ) {
$manager->enqueue(); $manager->enqueue();
} }
} }
@ -222,8 +220,8 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
// Render submit button. // Render submit button.
add_action( add_action(
$manager->checkout_button_renderer_hook(), $manager->checkout_button_renderer_hook(),
static function () use ( $c, $manager, $module ) { function () use ( $c, $manager ) {
if ( $module->should_render_fastlane( $c ) ) { if ( $this->should_render_fastlane( $c ) ) {
$manager->render_checkout_button(); $manager->render_checkout_button();
} }
} }
@ -278,14 +276,14 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
add_filter( add_filter(
'woocommerce_paypal_payments_localized_script_data', 'woocommerce_paypal_payments_localized_script_data',
function( array $localized_script_data ) use ( $c, $module ) { function( array $localized_script_data ) use ( $c ) {
$api = $c->get( 'api.sdk-client-token' ); $api = $c->get( 'api.sdk-client-token' );
assert( $api instanceof SdkClientToken ); assert( $api instanceof SdkClientToken );
$logger = $c->get( 'woocommerce.logger.woocommerce' ); $logger = $c->get( 'woocommerce.logger.woocommerce' );
assert( $logger instanceof LoggerInterface ); assert( $logger instanceof LoggerInterface );
return $module->add_sdk_client_token_to_script_data( $api, $logger, $localized_script_data ); return $this->add_sdk_client_token_to_script_data( $api, $logger, $localized_script_data );
} }
); );
@ -349,6 +347,26 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
} }
); );
// Remove Fastlane on the Pay for Order page.
add_filter(
'woocommerce_available_payment_gateways',
/**
* Param types removed to avoid third-party issues.
*
* @psalm-suppress MissingClosureParamType
*/
static function ( $methods ) {
if ( ! is_array( $methods ) || ! is_wc_endpoint_url( 'order-pay' ) ) {
return $methods;
}
// Remove Fastlane if present.
unset( $methods[ AxoGateway::ID ] );
return $methods;
}
);
return true; return true;
} }
@ -403,6 +421,7 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
* @return bool * @return bool
*/ */
private function should_render_fastlane( ContainerInterface $c ): bool { private function should_render_fastlane( ContainerInterface $c ): bool {
$dcc_configuration = $c->get( 'wcgateway.configuration.card-configuration' ); $dcc_configuration = $c->get( 'wcgateway.configuration.card-configuration' );
assert( $dcc_configuration instanceof CardPaymentsConfiguration ); assert( $dcc_configuration instanceof CardPaymentsConfiguration );

View file

@ -466,6 +466,10 @@ class SmartButton implements SmartButtonInterface {
* @return bool * @return bool
*/ */
private function render_message_wrapper_registrar(): bool { private function render_message_wrapper_registrar(): bool {
if ( ! apply_filters( 'woocommerce_paypal_payments_should_render_pay_later_messaging', true ) ) {
return false;
}
if ( ! $this->settings_status->is_pay_later_messaging_enabled() || ! $this->settings_status->has_pay_later_messaging_locations() ) { if ( ! $this->settings_status->is_pay_later_messaging_enabled() || ! $this->settings_status->has_pay_later_messaging_locations() ) {
return false; return false;
} }

View file

@ -20,7 +20,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory
class ThreeDSecure { class ThreeDSecure {
public const NO_DECISION = 0; public const NO_DECISION = 0;
public const PROCEED = 1; public const PROCEED = 1;
public const REJECT = 2; public const REJECT = 2;
public const RETRY = 3; public const RETRY = 3;

View file

@ -10,6 +10,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Compat\Settings; namespace WooCommerce\PayPalCommerce\Compat\Settings;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway; use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Settings\Data\AbstractDataModel;
use WooCommerce\PayPalCommerce\Settings\Data\PaymentSettings;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
/** /**
@ -20,6 +22,15 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
*/ */
class PaymentMethodSettingsMapHelper { class PaymentMethodSettingsMapHelper {
/**
* A map of new to old 3d secure values.
*/
protected const THREE_D_SECURE_VALUES_MAP = array(
'no-3d-secure' => 'NO_3D_SECURE',
'only-required-3d-secure' => 'SCA_WHEN_REQUIRED',
'always-3d-secure' => 'SCA_ALWAYS',
);
/** /**
* Maps old setting keys to new payment method settings names. * Maps old setting keys to new payment method settings names.
* *
@ -27,26 +38,39 @@ class PaymentMethodSettingsMapHelper {
*/ */
public function map(): array { public function map(): array {
return array( return array(
'dcc_enabled' => CreditCardGateway::ID, 'dcc_enabled' => CreditCardGateway::ID,
'axo_enabled' => AxoGateway::ID, 'axo_enabled' => AxoGateway::ID,
'3d_secure_contingency' => 'three_d_secure',
); );
} }
/** /**
* Retrieves the value of a mapped key from the new settings. * Retrieves the value of a mapped key from the new settings.
* *
* @param string $old_key The key from the legacy settings. * @param string $old_key The key from the legacy settings.
* @param AbstractDataModel|null $payment_settings The payment settings model.
* @return mixed The value of the mapped setting, (null if not found). * @return mixed The value of the mapped setting, (null if not found).
*/ */
public function mapped_value( string $old_key ): ?bool { public function mapped_value( string $old_key, ?AbstractDataModel $payment_settings ) {
switch ( $old_key ) {
case '3d_secure_contingency':
if ( is_null( $payment_settings ) ) {
return null;
}
$payment_method = $this->map()[ $old_key ] ?? false; assert( $payment_settings instanceof PaymentSettings );
$selected_three_d_secure = $payment_settings->get_three_d_secure();
return self::THREE_D_SECURE_VALUES_MAP[ $selected_three_d_secure ] ?? null;
if ( ! $payment_method ) { default:
return null; $payment_method = $this->map()[ $old_key ] ?? false;
if ( ! $payment_method ) {
return null;
}
return $this->is_gateway_enabled( $payment_method );
} }
return $this->is_gateway_enabled( $payment_method );
} }
/** /**

View file

@ -214,7 +214,7 @@ class SettingsMapHelper {
: $this->settings_tab_map_helper->mapped_value( $old_key, $this->model_cache[ $model_id ] ); : $this->settings_tab_map_helper->mapped_value( $old_key, $this->model_cache[ $model_id ] );
case $model instanceof PaymentSettings: case $model instanceof PaymentSettings:
return $this->payment_method_settings_map_helper->mapped_value( $old_key ); return $this->payment_method_settings_map_helper->mapped_value( $old_key, $this->get_payment_settings_model() );
default: default:
return $this->model_cache[ $model_id ][ $new_key ] ?? null; return $this->model_cache[ $model_id ][ $new_key ] ?? null;

View file

@ -1,8 +1,6 @@
import { __ } from '@wordpress/i18n';
document.addEventListener( 'DOMContentLoaded', () => { document.addEventListener( 'DOMContentLoaded', () => {
const disableFields = ( productId ) => { const disableFields = ( productId ) => {
const variations = document.querySelector( '.woocommerce_variations' ); const variations = document.querySelector( '.woocommerce_variations' );
if ( variations ) { if ( variations ) {
const children = variations.children; const children = variations.children;
for ( let i = 0; i < children.length; i++ ) { for ( let i = 0; i < children.length; i++ ) {
@ -70,156 +68,232 @@ document.addEventListener( 'DOMContentLoaded', () => {
soldIndividually.setAttribute( 'disabled', 'disabled' ); soldIndividually.setAttribute( 'disabled', 'disabled' );
}; };
const checkSubscriptionPeriodsInterval = (period, period_interval, price, linkBtn) => { const checkSubscriptionPeriodsInterval = (
if ( period,
( period === 'year' && parseInt( period_interval ) > 1 ) || period_interval,
( period === 'month' && parseInt( period_interval ) > 12 ) || price,
( period === 'week' && parseInt( period_interval ) > 52 ) || linkBtn
( period === 'day' && parseInt( period_interval ) > 356 ) || ) => {
( ! price || parseInt( price ) <= 0 ) if ( ! linkBtn ) {
) { return;
linkBtn.disabled = true; }
linkBtn.checked = false;
if (! price || parseInt( price ) <= 0 ) {
linkBtn.setAttribute('title', __( 'Prices must be above zero for PayPal Subscriptions!', 'woocommerce-paypal-subscriptions' ) );
} else {
linkBtn.setAttribute('title', __( 'Not allowed period interval combination for PayPal Subscriptions!', 'woocommerce-paypal-subscriptions' ) );
}
} else { if (
linkBtn.disabled = false; ( period === 'year' && parseInt( period_interval ) > 1 ) ||
linkBtn.removeAttribute('title'); ( period === 'month' && parseInt( period_interval ) > 12 ) ||
} ( period === 'week' && parseInt( period_interval ) > 52 ) ||
} ( period === 'day' && parseInt( period_interval ) > 356 ) ||
! price ||
parseInt( price ) <= 0
) {
linkBtn.disabled = true;
linkBtn.checked = false;
if ( ! price || parseInt( price ) <= 0 ) {
linkBtn.setAttribute(
'title',
PayPalCommerceGatewayPayPalSubscriptionProducts.i18n
.prices_must_be_above_zero
);
} else {
linkBtn.setAttribute(
'title',
PayPalCommerceGatewayPayPalSubscriptionProducts.i18n
.not_allowed_period_interval
);
}
} else {
linkBtn.disabled = false;
linkBtn.removeAttribute( 'title' );
}
};
const setupProducts = () => { const setupProducts = () => {
jQuery( '.wc_input_subscription_period' ).on( 'change', (e) => { jQuery( '.wc_input_subscription_period' ).on( 'change', ( e ) => {
const linkBtn = e.target.parentElement.parentElement.parentElement.parentElement.querySelector('input[name="_ppcp_enable_subscription_product"]'); const linkBtn =
const period_interval = e.target.parentElement.querySelector('select.wc_input_subscription_period_interval')?.value; e.target.parentElement.parentElement.parentElement.parentElement.querySelector(
const period = e.target.value; 'input[name="_ppcp_enable_subscription_product"]'
const price = e.target.parentElement.querySelector('input.wc_input_subscription_price')?.value; );
if ( linkBtn ) {
const period_interval = e.target.parentElement.querySelector(
'select.wc_input_subscription_period_interval'
)?.value;
const period = e.target.value;
const price = e.target.parentElement.querySelector(
'input.wc_input_subscription_price'
)?.value;
checkSubscriptionPeriodsInterval(period, period_interval, price, linkBtn); checkSubscriptionPeriodsInterval(
}); period,
period_interval,
price,
linkBtn
);
}
} );
jQuery( '.wc_input_subscription_period_interval' ).on( 'change', (e) => { jQuery( '.wc_input_subscription_period_interval' ).on(
const linkBtn = e.target.parentElement.parentElement.parentElement.parentElement.querySelector('input[name="_ppcp_enable_subscription_product"]'); 'change',
const period_interval = e.target.value; ( e ) => {
const period = e.target.parentElement.querySelector('select.wc_input_subscription_period')?.value; const linkBtn =
const price = e.target.parentElement.querySelector('input.wc_input_subscription_price')?.value; e.target.parentElement.parentElement.parentElement.parentElement.querySelector(
'input[name="_ppcp_enable_subscription_product"]'
);
if ( linkBtn ) {
const period_interval = e.target.value;
const period = e.target.parentElement.querySelector(
'select.wc_input_subscription_period'
)?.value;
const price = e.target.parentElement.querySelector(
'input.wc_input_subscription_price'
)?.value;
checkSubscriptionPeriodsInterval(period, period_interval, price, linkBtn); checkSubscriptionPeriodsInterval(
}); period,
period_interval,
price,
linkBtn
);
}
}
);
jQuery( '.wc_input_subscription_price' ).on( 'change', (e) => { jQuery( '.wc_input_subscription_price' ).on( 'change', ( e ) => {
const linkBtn = e.target.parentElement.parentElement.parentElement.parentElement.querySelector('input[name="_ppcp_enable_subscription_product"]'); const linkBtn =
const period_interval = e.target.parentElement.querySelector('select.wc_input_subscription_period_interval')?.value; e.target.parentElement.parentElement.parentElement.parentElement.querySelector(
const period = e.target.parentElement.querySelector('select.wc_input_subscription_period')?.value; 'input[name="_ppcp_enable_subscription_product"]'
const price = e.target.value; );
if ( linkBtn ) {
const period_interval = e.target.parentElement.querySelector(
'select.wc_input_subscription_period_interval'
)?.value;
const period = e.target.parentElement.querySelector(
'select.wc_input_subscription_period'
)?.value;
const price = e.target.value;
checkSubscriptionPeriodsInterval(period, period_interval, price, linkBtn); checkSubscriptionPeriodsInterval(
}); period,
period_interval,
price,
linkBtn
);
}
} );
jQuery( '.wc_input_subscription_price' ).trigger( 'change' ); jQuery( '.wc_input_subscription_price' ).trigger( 'change' );
let variationProductIds = [ PayPalCommerceGatewayPayPalSubscriptionProducts.product_id ]; const variationProductIds = [
const variationsInput = document.querySelectorAll( '.variable_post_id' ); PayPalCommerceGatewayPayPalSubscriptionProducts.product_id,
for ( let i = 0; i < variationsInput.length; i++ ) { ];
variationProductIds.push( variationsInput[ i ].value ); const variationsInput =
} document.querySelectorAll( '.variable_post_id' );
for ( let i = 0; i < variationsInput.length; i++ ) {
variationProductIds.push( variationsInput[ i ].value );
}
variationProductIds?.forEach( variationProductIds?.forEach( ( productId ) => {
( productId ) => { const linkBtn = document.getElementById(
const linkBtn = document.getElementById( `ppcp_enable_subscription_product-${ productId }`
`ppcp_enable_subscription_product-${ productId }` );
); if ( linkBtn ) {
if ( linkBtn.checked && linkBtn.value === 'yes' ) { if ( linkBtn.checked && linkBtn.value === 'yes' ) {
disableFields( productId ); disableFields( productId );
} }
linkBtn?.addEventListener( 'click', ( event ) => { linkBtn.addEventListener( 'click', ( event ) => {
const unlinkBtnP = document.getElementById( const unlinkBtnP = document.getElementById(
`ppcp-enable-subscription-${ productId }` `ppcp-enable-subscription-${ productId }`
);
const titleP = document.getElementById(
`ppcp_subscription_plan_name_p-${ productId }`
);
if (event.target.checked === true) {
if ( unlinkBtnP ) {
unlinkBtnP.style.display = 'none';
}
if ( titleP ) {
titleP.style.display = 'block';
}
} else {
if ( unlinkBtnP ) {
unlinkBtnP.style.display = 'block';
}
if ( titleP ) {
titleP.style.display = 'none';
}
}
});
const unlinkBtn = document.getElementById(
`ppcp-unlink-sub-plan-${ productId }`
);
unlinkBtn?.addEventListener( 'click', ( event ) => {
event.preventDefault();
unlinkBtn.disabled = true;
const spinner = document.getElementById(
`spinner-unlink-plan-${ productId }`
); );
spinner.style.display = 'inline-block'; const titleP = document.getElementById(
`ppcp_subscription_plan_name_p-${ productId }`
);
if ( event.target.checked === true ) {
if ( unlinkBtnP ) {
unlinkBtnP.style.display = 'none';
}
if ( titleP ) {
titleP.style.display = 'block';
}
} else {
if ( unlinkBtnP ) {
unlinkBtnP.style.display = 'block';
}
if ( titleP ) {
titleP.style.display = 'none';
}
}
} );
}
fetch( PayPalCommerceGatewayPayPalSubscriptionProducts.ajax.deactivate_plan.endpoint, { const unlinkBtn = document.getElementById(
`ppcp-unlink-sub-plan-${ productId }`
);
unlinkBtn?.addEventListener( 'click', ( event ) => {
event.preventDefault();
unlinkBtn.disabled = true;
const spinner = document.getElementById(
`spinner-unlink-plan-${ productId }`
);
spinner.style.display = 'inline-block';
fetch(
PayPalCommerceGatewayPayPalSubscriptionProducts.ajax
.deactivate_plan.endpoint,
{
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify( { body: JSON.stringify( {
nonce: PayPalCommerceGatewayPayPalSubscriptionProducts.ajax.deactivate_plan.nonce, nonce: PayPalCommerceGatewayPayPalSubscriptionProducts
.ajax.deactivate_plan.nonce,
plan_id: linkBtn.dataset.subsPlan, plan_id: linkBtn.dataset.subsPlan,
product_id: productId, product_id: productId,
} ), } ),
}
)
.then( function ( res ) {
return res.json();
} ) } )
.then( function ( res ) { .then( function ( data ) {
return res.json(); if ( ! data.success ) {
} ) unlinkBtn.disabled = false;
.then( function ( data ) { spinner.style.display = 'none';
if ( ! data.success ) { console.error( data );
unlinkBtn.disabled = false; throw Error( data.data.message );
spinner.style.display = 'none'; }
console.error( data );
throw Error( data.data.message );
}
const enableSubscription = document.getElementById( const enableSubscription = document.getElementById(
'ppcp-enable-subscription-' + data.data.product_id 'ppcp-enable-subscription-' + data.data.product_id
);
const product = document.getElementById(
'pcpp-product-' + data.data.product_id
);
const plan = document.getElementById(
'pcpp-plan-' + data.data.product_id
);
enableSubscription.style.display = 'none';
product.style.display = 'none';
plan.style.display = 'none';
const enable_subscription_product =
document.getElementById(
'ppcp_enable_subscription_product-' +
data.data.product_id
); );
const product = document.getElementById( 'pcpp-product-' + data.data.product_id ); enable_subscription_product.disabled = true;
const plan = document.getElementById( 'pcpp-plan-' + data.data.product_id );
enableSubscription.style.display = 'none';
product.style.display = 'none';
plan.style.display = 'none';
const enable_subscription_product = const planUnlinked = document.getElementById(
document.getElementById( 'pcpp-plan-unlinked-' + data.data.product_id
'ppcp_enable_subscription_product-' + data.data.product_id );
); planUnlinked.style.display = 'block';
enable_subscription_product.disabled = true;
const planUnlinked = setTimeout( () => {
document.getElementById( 'pcpp-plan-unlinked-' + data.data.product_id ); location.reload();
planUnlinked.style.display = 'block'; }, 1000 );
} );
setTimeout( () => { } );
location.reload(); } );
}, 1000 );
} );
} );
}
);
}; };
setupProducts(); setupProducts();

View file

@ -581,6 +581,10 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu
), ),
), ),
'product_id' => $product->get_id(), 'product_id' => $product->get_id(),
'i18n' => array(
'prices_must_be_above_zero' => __( 'Prices must be above zero for PayPal Subscriptions!', 'woocommerce-paypal-payments' ),
'not_allowed_period_interval' => __( 'Not allowed period interval combination for PayPal Subscriptions!', 'woocommerce-paypal-payments' ),
),
) )
); );
} }

View file

@ -3,13 +3,6 @@
*/ */
.ppcp-r-app.loading { .ppcp-r-app.loading {
height: 400px;
width: 400px;
position: absolute;
left: 50%;
transform: translate(-50%, 0);
text-align: center;
.ppcp-r-spinner-overlay { .ppcp-r-spinner-overlay {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -70,6 +70,15 @@ button.components-button, a.components-button {
--button-disabled-color: #{$color-gray-100}; --button-disabled-color: #{$color-gray-100};
--button-disabled-background: #{$color-gray-500}; --button-disabled-background: #{$color-gray-500};
&:hover:not(:disabled) {
background: #{$color-blue};
}
&:not(.components-tab-panel__tabs-item):focus-visible:not(:disabled) {
outline: 2px solid #{$color-gray-500};
}
} }
&.is-secondary { &.is-secondary {
@ -86,7 +95,7 @@ button.components-button, a.components-button {
--button-color: #{$color-blueberry}; --button-color: #{$color-blueberry};
--button-hover-color: #{$color-gradient-dark}; --button-hover-color: #{$color-gradient-dark};
&:focus:not(:disabled) { &:focus-visible:not(:disabled) {
border: none; border: none;
box-shadow: none; box-shadow: none;
} }
@ -95,6 +104,16 @@ button.components-button, a.components-button {
&.small-button { &.small-button {
@include small-button; @include small-button;
} }
&:focus:not(:disabled) {
outline: none;
}
&:focus-visible:not(:disabled),
&:not(.components-tab-panel__tabs-item):focus-visible:not(:disabled)
&[data-focus-visible="true"] {
outline: 2px solid #{$color-blueberry};
}
} }
.ppcp--is-loading { .ppcp--is-loading {

View file

@ -111,12 +111,7 @@
margin: 0; margin: 0;
} }
} }
// Custom styles. // Custom styles.
.components-form-toggle.is-checked > .components-form-toggle__track {
background-color: $color-blueberry;
}
.ppcp-r-vertical-text-control { .ppcp-r-vertical-text-control {
.components-base-control__field { .components-base-control__field {
display: flex; display: flex;
@ -126,3 +121,74 @@
} }
} }
} }
.ppcp-r-app, .ppcp-r-modal__container {
// Form toggle styling.
.components-form-toggle {
&.is-checked {
> .components-form-toggle__track {
background-color: $color-blueberry;
}
.components-form-toggle__track {
border-color: $color-blueberry;
}
}
.components-form-toggle__input {
&:focus {
+ .components-form-toggle__track {
box-shadow: none;
}
}
&:focus-visible + .components-form-toggle__track {
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) #fff,
0 0 0 calc(var(--wp-admin-border-width-focus)*2) $color-blueberry;
}
}
}
// Form inputs.
.components-text-control__input {
&:focus,
&[type="color"]:focus,
&[type="date"]:focus,
&[type="datetime-local"]:focus,
&[type="datetime"]:focus,
&[type="email"]:focus,
&[type="month"]:focus,
&[type="number"]:focus,
&[type="password"]:focus,
&[type="tel"]:focus,
&[type="text"]:focus,
&[type="time"]:focus,
&[type="url"]:focus,
&[type="week"]:focus {
border-color: $color-blueberry;
}
}
// Radio inputs.
.components-radio-control__input[type="radio"] {
&:checked {
background-color: $color-blueberry;
border-color: $color-blueberry;
}
&:focus {
box-shadow: none;
}
&:focus-visible {
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) #fff,
0 0 0 calc(var(--wp-admin-border-width-focus)*2) $color-blueberry;
}
}
// Checkbox inputs.
.components-checkbox-control__input[type="checkbox"] {
&:focus {
box-shadow: none;
}
&:focus-visible {
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) #fff,
0 0 0 calc(var(--wp-admin-border-width-focus)*2) $color-blueberry;
}
}
}

View file

@ -103,6 +103,11 @@ $margin_bottom: 48px;
.components-tab-panel__tabs-item { .components-tab-panel__tabs-item {
height: var(--subnavigation-height); height: var(--subnavigation-height);
&:focus-visible:not(:disabled),
&[data-focus-visible="true"]:focus:not(:disabled) {
outline: none;
}
} }
} }

View file

@ -60,7 +60,7 @@ $width_gap: 24px;
.ppcp-r-settings-card__title { .ppcp-r-settings-card__title {
@include font(13, 24, 600); @include font(13, 24, 600);
color: var(--color-text-main); color: var(--color-text-main);
margin: 0 0 4px 0; margin: 0 0 12px 0;
display: block; display: block;
} }
@ -68,6 +68,16 @@ $width_gap: 24px;
@include font(13, 20, 400); @include font(13, 20, 400);
color: var(--color-text-teriary); color: var(--color-text-teriary);
margin: 0; margin: 0;
p {
margin: 0 0 12px 0;
}
button {
padding: 0;
margin: 0;
}
} }
+ .ppcp-r-settings-card { + .ppcp-r-settings-card {

View file

@ -1,5 +1,4 @@
.ppcp-r-spinner-overlay { .ppcp-r-spinner-overlay {
background: var(--spinner-overlay-color);
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -13,8 +12,6 @@
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
margin: 0; margin: 0;
width: var(--spinner-size);
height: var(--spinner-size);
} }
.ppcp--spinner-message { .ppcp--spinner-message {
@ -29,7 +26,6 @@
position: fixed; position: fixed;
width: var(--spinner-overlay-width); width: var(--spinner-overlay-width);
height: var(--spinner-overlay-height); height: var(--spinner-overlay-height);
box-shadow: var(--spinner-overlay-box-shadow);
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);

View file

@ -17,3 +17,18 @@
} }
} }
} }
.ppcp-r-modal {
button.components-button,
a.components-button {
&:focus:not(:disabled) {
outline: none;
}
&:focus-visible:not(:disabled),
&:not(.components-tab-panel__tabs-item):focus-visible:not(:disabled)
&[data-focus-visible="true"] {
outline: 2px solid #{$color-blueberry};
}
}
}

View file

@ -103,7 +103,7 @@
&__dismiss { &__dismiss {
position: absolute; position: absolute;
right: 0; right: 2px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
background-color: transparent; background-color: transparent;

View file

@ -1,19 +1,31 @@
.ppcp-r-paylater-configurator { .ppcp-r-paylater-configurator {
display: flex; display: flex;
border: 1px solid var(--color-separators);
border-radius: var(--container-border-radius); border-radius: var(--container-border-radius);
overflow: hidden; overflow: hidden;
font-family: "PayPalPro", sans-serif; font-family: "PayPalPro", sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
width: 1200px;
// Reset box-sizing for the preview container.
.etu8a6w3 * {
box-sizing: unset;
}
.css-1snxoyf.eolpigi0 { .css-1snxoyf.eolpigi0 {
margin: 0; margin: 0;
} }
.css-1f9aeda {
width: 100%;
}
.css-1adsww8 {
padding: 0;
}
#configurator-eligibleContainer.css-4nclxm.e1vy3g880 { #configurator-eligibleContainer.css-4nclxm.e1vy3g880 {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
padding: 16px 0px 16px 16px;
#configurator-controlPanelContainer.css-5urmrq.e1vy3g880 { #configurator-controlPanelContainer.css-5urmrq.e1vy3g880 {
width: 374px; width: 374px;
@ -43,7 +55,7 @@
} }
.css-8vwtr6-state { .css-8vwtr6-state {
height: 1.4rem; height: 1.5rem;
width: 3rem; width: 3rem;
} }
} }
@ -54,17 +66,27 @@
} }
&__subheader, #configurator-controlPanelSubHeader { &__subheader, #configurator-controlPanelSubHeader {
color: var(--color-text-description); color: var(--color-text-teriary);
margin: 0 0 18px 0; margin: 0 0 18px 0;
@include font(13, 20, 400);
} }
&__header, #configurator-controlPanelHeader, #configurator-previewSectionSubHeaderText.css-14ujlqd-text_body, .css-16jt5za-text_body { &__header, #configurator-controlPanelHeader, #configurator-previewSectionSubHeaderText.css-14ujlqd-text_body, .css-16jt5za-text_body {
@include font(16, 20, 600); @include font(13, 20, 600);
color: var(--color-text-title); color: var(--color-text-title);
margin-bottom: 6px;
font-family: "PayPalPro", sans-serif; font-family: "PayPalPro", sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
&__header,
#configurator-controlPanelHeader {
margin-bottom: 12px;
}
#configurator-previewSectionSubHeaderText.css-14ujlqd-text_body,
.css-16jt5za-text_body {
color: var(--color-text-teriary);
}
.css-1yo2lxy-text_body_strong { .css-1yo2lxy-text_body_strong {
color: var(--color-text-description); color: var(--color-text-description);
@ -73,8 +95,9 @@
} }
.css-rok10q, .css-dfgbdq-text_body_strong { .css-rok10q, .css-dfgbdq-text_body_strong {
margin-top: 0; margin: 0 0 12px 0;
margin-bottom: 0; padding: 0;
width: 100%;
} }
&__publish-button { &__publish-button {
@ -109,9 +132,9 @@
display: none; display: none;
} }
.css-4nclxm.e1vy3g880, { .css-4nclxm.e1vy3g880 {
width: 100%; width: 100%;
padding: 48px 8px; padding: 0 0 48px 0;
.css-11hsg2u.e1vy3g880 { .css-11hsg2u.e1vy3g880 {
width: 100%; width: 100%;
@ -119,7 +142,7 @@
} }
.css-n4cwz8 { .css-n4cwz8 {
margin-top: 20px; margin-top: 48px;
} }
.css-1ce6bcu-container { .css-1ce6bcu-container {

View file

@ -1,7 +1,6 @@
import { Icon } from '@wordpress/components'; import { Icon } from '@wordpress/components';
import { chevronDown, chevronUp } from '@wordpress/icons'; import { chevronDown, chevronUp } from '@wordpress/icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { useToggleState } from '../../hooks/useToggleState'; import { useToggleState } from '../../hooks/useToggleState';
import { import {
Content, Content,
@ -22,33 +21,44 @@ const Accordion = ( {
className = '', className = '',
} ) => { } ) => {
const { isOpen, toggleOpen } = useToggleState( id, initiallyOpen ); const { isOpen, toggleOpen } = useToggleState( id, initiallyOpen );
const wrapperClasses = classNames( 'ppcp-r-accordion', className, { const contentId = id
'ppcp--is-open': isOpen, ? `${ id }-content`
} ); : `accordion-${ title.replace( /\s+/g, '-' ).toLowerCase() }-content`;
const contentClass = classNames( 'ppcp--accordion-content', {
'ppcp--is-open': isOpen,
} );
const icon = isOpen ? chevronUp : chevronDown;
return ( return (
<div className={ wrapperClasses } { ...( id && { id } ) }> <div
className={ classNames( 'ppcp-r-accordion', className, {
'ppcp--is-open': isOpen,
} ) }
id={ id || undefined }
>
<button <button
type="button" type="button"
className="ppcp--toggler" className="ppcp--toggler"
onClick={ toggleOpen } onClick={ toggleOpen }
aria-expanded={ isOpen }
aria-controls={ contentId }
> >
<Header> <Header>
<TitleWrapper> <TitleWrapper>
<Title noCaps={ noCaps }>{ title }</Title> <Title noCaps={ noCaps }>{ title }</Title>
<Action> <Action>
<Icon icon={ icon } /> <Icon icon={ isOpen ? chevronUp : chevronDown } />
</Action> </Action>
</TitleWrapper> </TitleWrapper>
<Description>{ description }</Description> { description && (
<Description>{ description }</Description>
) }
</Header> </Header>
</button> </button>
<div className={ contentClass }> <div
className={ classNames( 'ppcp--accordion-content', {
'ppcp--is-open': isOpen,
} ) }
id={ contentId }
aria-hidden={ ! isOpen }
inert={ isOpen ? undefined : '' }
>
<Content asCard={ false }>{ children }</Content> <Content asCard={ false }>{ children }</Content>
</div> </div>
</div> </div>

View file

@ -1,58 +1,40 @@
import { Button } from '@wordpress/components'; import { Button } from '@wordpress/components';
import { Header, Title, Action, Description } from '../Elements'; import { Header, Title, Action, Description } from '../Elements';
import SettingsBlock from '../SettingsBlock'; import SettingsBlock from '../SettingsBlock';
import TitleBadge from '../TitleBadge'; import TitleBadge from '../TitleBadge';
import { CommonHooks } from '../../../data';
/**
* Renders a feature settings block with title, description, and action buttons.
*
* @param {Object} props Component properties
* @param {string} props.title The feature title
* @param {string} props.description HTML description of the feature
* @return {JSX.Element} The rendered component
*/
const FeatureSettingsBlock = ( { title, description, ...props } ) => { const FeatureSettingsBlock = ( { title, description, ...props } ) => {
const printNotes = () => { const { actionProps } = props;
const notes = props.actionProps?.notes; const { isSandbox } = CommonHooks.useMerchant();
if ( ! notes || ( Array.isArray( notes ) && notes.length === 0 ) ) {
return null; /**
* Gets the appropriate URL for a button based on environment
* Always prioritizes urls object over url when it exists
*
* @param {Object} buttonData The button configuration object
* @param {string} [buttonData.url] Single URL for the button
* @param {Object} [buttonData.urls] Environment-specific URLs
* @param {string} [buttonData.urls.sandbox] URL for sandbox environment
* @param {string} [buttonData.urls.live] URL for live environment
* @return {string|undefined} The appropriate URL to use for the button
*/
const getButtonUrl = ( buttonData ) => {
const { url, urls } = buttonData;
if ( urls ) {
return isSandbox ? urls.sandbox : urls.live;
} }
return ( return url;
<span className="ppcp--item-notes">
{ notes.map( ( note, index ) => (
<span key={ index }>{ note }</span>
) ) }
</span>
);
};
const FeatureButton = ( {
className,
variant,
text,
isBusy,
url,
urls,
onClick,
} ) => {
const buttonProps = {
className,
isBusy,
variant,
};
if ( url || urls ) {
buttonProps.href = urls ? urls.live : url;
buttonProps.target = '_blank';
}
if ( ! buttonProps.href ) {
buttonProps.onClick = onClick;
}
return <Button { ...buttonProps }>{ text }</Button>;
};
const renderDescription = () => {
return (
<span
className="ppcp-r-feature-item__description ppcp-r-settings-block__feature__description"
dangerouslySetInnerHTML={ { __html: description } }
/>
);
}; };
return ( return (
@ -60,38 +42,52 @@ const FeatureSettingsBlock = ( { title, description, ...props } ) => {
<Header> <Header>
<Title> <Title>
{ title } { title }
{ props.actionProps?.enabled && ( { actionProps?.enabled && (
<TitleBadge { ...props.actionProps?.badge } /> <TitleBadge { ...actionProps?.badge } />
) } ) }
</Title> </Title>
<Description className="ppcp-r-settings-block__feature__description"> <Description className="ppcp-r-settings-block__feature__description">
{ renderDescription() } <span
{ printNotes() } className="ppcp-r-feature-item__description"
dangerouslySetInnerHTML={ { __html: description } }
/>
{ actionProps?.notes?.length > 0 && (
<span className="ppcp--item-notes">
{ actionProps.notes.map( ( note, index ) => (
<span key={ index }>{ note }</span>
) ) }
</span>
) }
</Description> </Description>
</Header> </Header>
<Action> <Action>
<div className="ppcp--action-buttons"> <div className="ppcp--action-buttons">
{ props.actionProps?.buttons.map( { actionProps?.buttons.map( ( buttonData ) => {
( { const {
class: className, class: className,
type, type,
text, text,
url,
urls,
onClick, onClick,
} ) => ( } = buttonData;
<FeatureButton
const buttonUrl = getButtonUrl( buttonData );
return (
<Button
key={ text } key={ text }
className={ className } className={ className }
variant={ type } variant={ type }
text={ text } isBusy={ actionProps.isBusy }
isBusy={ props.actionProps.isBusy } href={ buttonUrl }
url={ url } target={ buttonUrl ? '_blank' : undefined }
urls={ urls } onClick={ ! buttonUrl ? onClick : undefined }
onClick={ onClick } >
/> { text }
) </Button>
) } );
} ) }
</div> </div>
</Action> </Action>
</SettingsBlock> </SettingsBlock>

View file

@ -31,10 +31,15 @@ const PaymentMethodItemBlock = ( {
id={ paymentMethod.id } id={ paymentMethod.id }
className={ methodItemClasses } className={ methodItemClasses }
separatorAndGap={ false } separatorAndGap={ false }
aria-disabled={ isDisabled ? 'true' : 'false' }
> >
{ isDisabled && ( { isDisabled && (
<div className="ppcp--method-disabled-overlay"> <div
<p className="ppcp--method-disabled-message"> className="ppcp--method-disabled-overlay"
role="alert"
aria-live="polite"
>
<p className="ppcp--method-disabled-message" tabIndex="0">
{ disabledMessage } { disabledMessage }
</p> </p>
</div> </div>
@ -60,6 +65,8 @@ const PaymentMethodItemBlock = ( {
__nextHasNoMarginBottom __nextHasNoMarginBottom
checked={ isSelected } checked={ isSelected }
onChange={ onSelect } onChange={ onSelect }
disabled={ isDisabled }
aria-label={ `Enable ${ paymentMethod.itemTitle }` }
/> />
{ hasWarning && ! isDisabled && isSelected && ( { hasWarning && ! isDisabled && isSelected && (
<WarningMessages <WarningMessages
@ -70,7 +77,9 @@ const PaymentMethodItemBlock = ( {
{ paymentMethod?.fields && onTriggerModal && ( { paymentMethod?.fields && onTriggerModal && (
<Button <Button
className="ppcp--method-settings" className="ppcp--method-settings"
disabled={ isDisabled }
onClick={ onTriggerModal } onClick={ onTriggerModal }
aria-label={ `Configure ${ paymentMethod.itemTitle } settings` }
> >
<Icon icon={ cog } /> <Icon icon={ cog } />
</Button> </Button>

View file

@ -2,6 +2,18 @@ import classNames from 'classnames';
import { Content } from './Elements'; import { Content } from './Elements';
/**
* Renders a settings card.
*
* @param {Object} props Component properties
* @param {string} [props.id] Unique identifier for the card
* @param {string} [props.className] Additional CSS classes
* @param {string} props.title Card title
* @param {*} props.description Card description content
* @param {*} props.children Card content
* @param {boolean} [props.contentContainer=true] Whether to wrap content in a container
* @return {JSX.Element} The settings card component
*/
const SettingsCard = ( { const SettingsCard = ( {
id, id,
className, className,
@ -16,14 +28,20 @@ const SettingsCard = ( {
id, id,
}; };
const titleId = id ? `${ id }-title` : undefined;
const descriptionId = id ? `${ id }-description` : undefined;
return ( return (
<div { ...cardProps }> <div { ...cardProps } role="region" aria-labelledby={ titleId }>
<div className="ppcp-r-settings-card__header"> <div className="ppcp-r-settings-card__header">
<div className="ppcp-r-settings-card__content-inner"> <div className="ppcp-r-settings-card__content-inner">
<span className="ppcp-r-settings-card__title"> <h2 id={ titleId } className="ppcp-r-settings-card__title">
{ title } { title }
</span> </h2>
<div className="ppcp-r-settings-card__description"> <div
id={ descriptionId }
className="ppcp-r-settings-card__description"
>
{ description } { description }
</div> </div>
</div> </div>

View file

@ -2,20 +2,24 @@ import { __ } from '@wordpress/i18n';
import { Spinner } from '@wordpress/components'; import { Spinner } from '@wordpress/components';
import classnames from 'classnames'; import classnames from 'classnames';
const SpinnerOverlay = ( { asModal = false, message = null } ) => { /**
* Renders a loading spinner.
*
* @param {Object} props Component properties.
* @param {boolean} [props.asModal=false] Whether to display the spinner as a modal overlay.
* @param {string} [props.ariaLabel] Accessible label for screen readers.
* @return {JSX.Element} The spinner overlay component.
*/
const SpinnerOverlay = ( {
asModal = false,
ariaLabel = __( 'Loading…', 'woocommerce-paypal-payments' ),
} ) => {
const className = classnames( 'ppcp-r-spinner-overlay', { const className = classnames( 'ppcp-r-spinner-overlay', {
'ppcp--is-modal': asModal, 'ppcp--is-modal': asModal,
} ); } );
if ( null === message ) {
message = __( 'Loading…', 'woocommerce-paypal-payments' );
}
return ( return (
<div className={ className }> <div className={ className } role="status" aria-label={ ariaLabel }>
{ message && (
<span className="ppcp--spinner-message">{ message }</span>
) }
<Spinner /> <Spinner />
</div> </div>
); );

View file

@ -30,8 +30,16 @@ const TabBar = ( { tabs, activePanel, setActivePanel } ) => {
initialTabName={ activePanel } initialTabName={ activePanel }
onSelect={ updateActivePanel } onSelect={ updateActivePanel }
tabs={ tabs } tabs={ tabs }
orientation="horizontal"
selectOnMove={ false }
> >
{ () => '' } { ( tab ) => (
<div
className={ `ppcp-r-tabpanel-content ppcp-r-tabpanel-${ tab.name }` }
>
{ tab.render ? tab.render() : '' }
</div>
) }
</TabPanel> </TabPanel>
); );
}; };

View file

@ -10,6 +10,7 @@ import PaymentFlow from '../Components/PaymentFlow';
const StepPaymentMethods = () => { const StepPaymentMethods = () => {
const { optionalMethods, setOptionalMethods } = const { optionalMethods, setOptionalMethods } =
OnboardingHooks.useOptionalPaymentMethods(); OnboardingHooks.useOptionalPaymentMethods();
const { ownBrandOnly } = CommonHooks.useWooSettings();
const { isCasualSeller } = OnboardingHooks.useBusiness(); const { isCasualSeller } = OnboardingHooks.useBusiness();
const optionalMethodTitle = useMemo( () => { const optionalMethodTitle = useMemo( () => {
@ -31,7 +32,10 @@ const StepPaymentMethods = () => {
description: <OptionalMethodDescription />, description: <OptionalMethodDescription />,
}, },
{ {
title: __( title: ownBrandOnly ? __(
'No thanks, I prefer to use a different provider for local payment methods',
'woocommerce-paypal-payments'
) : __(
'No thanks, I prefer to use a different provider for processing credit cards, digital wallets, and local payment methods', 'No thanks, I prefer to use a different provider for processing credit cards, digital wallets, and local payment methods',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'
), ),
@ -41,7 +45,9 @@ const StepPaymentMethods = () => {
return ( return (
<div className="ppcp-r-page-optional-payment-methods"> <div className="ppcp-r-page-optional-payment-methods">
<OnboardingHeader title={ <PaymentStepTitle /> } /> <OnboardingHeader
title={ <PaymentStepTitle isBrandedOnly={ ownBrandOnly } /> }
/>
<div className="ppcp-r-inner-container"> <div className="ppcp-r-inner-container">
<OptionSelector <OptionSelector
multiSelect={ false } multiSelect={ false }
@ -58,7 +64,13 @@ const StepPaymentMethods = () => {
export default StepPaymentMethods; export default StepPaymentMethods;
const PaymentStepTitle = () => { const PaymentStepTitle = ( ownBrandOnly ) => {
if ( ownBrandOnly.isBrandedOnly ) {
return __(
'Add Expanded Checkout for more ways to pay',
'woocommerce-paypal-payments'
);
}
return __( 'Add Credit and Debit Cards', 'woocommerce-paypal-payments' ); return __( 'Add Credit and Debit Cards', 'woocommerce-paypal-payments' );
}; };

View file

@ -22,15 +22,16 @@ const StepWelcome = ( { setStep, currentStep } ) => {
ownBrandOnly ownBrandOnly
); );
const onboardingHeaderDescription = canUseCardPayments const onboardingHeaderDescription =
? __( canUseCardPayments && ! ownBrandOnly
'Your all-in-one integration for PayPal checkout solutions that enable buyers to pay via PayPal, Pay Later, all major credit/debit cards, Apple Pay, Google Pay, and more.', ? __(
'woocommerce-paypal-payments' 'Your all-in-one integration for PayPal checkout solutions that enable buyers to pay via PayPal, Pay Later, all major credit/debit cards, Apple Pay, Google Pay, and more.',
) 'woocommerce-paypal-payments'
: __( )
'Your all-in-one integration for PayPal checkout solutions that enable buyers to pay via PayPal, Pay Later, all major credit/debit cards, and more.', : __(
'woocommerce-paypal-payments' 'Your all-in-one integration for PayPal checkout solutions that enable buyers to pay via PayPal, Pay Later, and more.',
); 'woocommerce-paypal-payments'
);
return ( return (
<div className="ppcp-r-page-welcome"> <div className="ppcp-r-page-welcome">

View file

@ -1,5 +1,6 @@
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { CommonHooks, OnboardingHooks } from '../../../../data';
import StepWelcome from './StepWelcome'; import StepWelcome from './StepWelcome';
import StepBusiness from './StepBusiness'; import StepBusiness from './StepBusiness';
import StepProducts from './StepProducts'; import StepProducts from './StepProducts';
@ -56,11 +57,17 @@ const filterSteps = ( steps, conditions ) => {
}; };
export const getSteps = ( flags ) => { export const getSteps = ( flags ) => {
const { ownBrandOnly } = CommonHooks.useWooSettings();
const { isCasualSeller } = OnboardingHooks.useBusiness();
const steps = filterSteps( ALL_STEPS, [ const steps = filterSteps( ALL_STEPS, [
// Casual selling: Unlock the "Personal Account" choice. // Casual selling: Unlock the "Personal Account" choice.
( step ) => flags.canUseCasualSelling || step.id !== 'business', ( step ) => flags.canUseCasualSelling || step.id !== 'business',
// Skip payment methods screen. // Skip payment methods screen.
( step ) => ! flags.shouldSkipPaymentMethods || step.id !== 'methods', ( step ) =>
step.id !== 'methods' ||
( ! flags.shouldSkipPaymentMethods &&
! ( ownBrandOnly && isCasualSeller ) ),
] ); ] );
const totalStepsCount = steps.length; const totalStepsCount = steps.length;

View file

@ -10,6 +10,15 @@ const OnboardingScreen = () => {
const Steps = getSteps( flags ); const Steps = getSteps( flags );
const currentStep = getCurrentStep( step, Steps ); const currentStep = getCurrentStep( step, Steps );
if ( ! currentStep?.StepComponent ) {
console.error( 'Invalid Onboarding State', {
step,
flags,
Steps,
currentStep,
} );
}
const handleNext = () => setStep( currentStep.nextStep ); const handleNext = () => setStep( currentStep.nextStep );
const handlePrev = () => setStep( currentStep.prevStep ); const handlePrev = () => setStep( currentStep.prevStep );

View file

@ -1,5 +1,6 @@
import { Button } from '@wordpress/components'; import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import TopNavigation from '../../../ReusableComponents/TopNavigation'; import TopNavigation from '../../../ReusableComponents/TopNavigation';
@ -21,8 +22,17 @@ const SettingsNavigation = ( {
setActivePanel = () => {}, setActivePanel = () => {},
} ) => { } ) => {
const { persistAll } = useStoreManager(); const { persistAll } = useStoreManager();
const title = __( 'PayPal Payments', 'woocommerce-paypal-payments' ); const title = __( 'PayPal Payments', 'woocommerce-paypal-payments' );
const [ isSaving, setIsSaving ] = useState( false );
const handleSave = () => {
setIsSaving( true );
speak(
__( 'Saving settings…', 'woocommerce-paypal-payments' ),
'assertive'
);
persistAll();
};
return ( return (
<TopNavigation <TopNavigation
@ -38,10 +48,19 @@ const SettingsNavigation = ( {
> >
{ canSave && ( { canSave && (
<> <>
<Button variant="primary" onClick={ persistAll }> <Button
{ __( 'Save', 'woocommerce-paypal-payments' ) } variant="primary"
onClick={ handleSave }
aria-busy={ isSaving }
>
{ isSaving
? __( 'Saving…', 'woocommerce-paypal-payments' )
: __( 'Save', 'woocommerce-paypal-payments' ) }
</Button> </Button>
<SaveStateMessage /> <SaveStateMessage
setIsSaving={ setIsSaving }
isSaving={ isSaving }
/>
</> </>
) } ) }
</TopNavigation> </TopNavigation>
@ -50,24 +69,26 @@ const SettingsNavigation = ( {
export default SettingsNavigation; export default SettingsNavigation;
const SaveStateMessage = () => { const SaveStateMessage = ( { setIsSaving, isSaving } ) => {
const [ isSaving, setIsSaving ] = useState( false );
const [ isVisible, setIsVisible ] = useState( false ); const [ isVisible, setIsVisible ] = useState( false );
const [ isAnimating, setIsAnimating ] = useState( false ); const [ isAnimating, setIsAnimating ] = useState( false );
const { onStarted, onFinished } = CommonHooks.useActivityObserver(); const { onStarted, onFinished } = CommonHooks.useActivityObserver();
const timerRef = useRef( null ); const timerRef = useRef( null );
const handleActivityStart = useCallback( ( started ) => { const handleActivityStart = useCallback(
if ( started.startsWith( 'persist' ) ) { ( started ) => {
setIsSaving( true ); if ( started.startsWith( 'persist' ) ) {
setIsVisible( false ); setIsSaving( true );
setIsAnimating( false ); setIsVisible( false );
setIsAnimating( false );
if ( timerRef.current ) { if ( timerRef.current ) {
clearTimeout( timerRef.current ); clearTimeout( timerRef.current );
}
} }
} },
}, [] ); [ setIsSaving ]
);
const handleActivityDone = useCallback( const handleActivityDone = useCallback(
( done, remaining ) => { ( done, remaining ) => {
@ -76,6 +97,14 @@ const SaveStateMessage = () => {
setIsVisible( true ); setIsVisible( true );
setTimeout( () => setIsAnimating( true ), 50 ); setTimeout( () => setIsAnimating( true ), 50 );
speak(
__(
'Settings saved successfully.',
'woocommerce-paypal-payments'
),
'assertive'
);
timerRef.current = setTimeout( () => { timerRef.current = setTimeout( () => {
setIsAnimating( false ); setIsAnimating( false );
setTimeout( setTimeout(
@ -85,7 +114,7 @@ const SaveStateMessage = () => {
}, SAVE_CONFIRMATION_DURATION ); }, SAVE_CONFIRMATION_DURATION );
} }
}, },
[ isSaving ] [ isSaving, setIsSaving ]
); );
useEffect( () => { useEffect( () => {
@ -102,7 +131,7 @@ const SaveStateMessage = () => {
} ); } );
return ( return (
<span className={ className }> <span className={ className } role="status" aria-live="polite">
<span className="ppcp--inner-text"> <span className="ppcp--inner-text">
{ __( 'Completed', 'woocommerce-paypal-payments' ) } { __( 'Completed', 'woocommerce-paypal-payments' ) }
</span> </span>

View file

@ -43,7 +43,10 @@ const Features = () => {
'Features refreshed successfully.', 'Features refreshed successfully.',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'
), ),
{ icon: NOTIFICATION_SUCCESS } {
icon: NOTIFICATION_SUCCESS,
speak: true,
}
); );
} else { } else {
throw new Error( throw new Error(
@ -58,7 +61,10 @@ const Features = () => {
error.message || error.message ||
__( 'Unknown error', 'woocommerce-paypal-payments' ) __( 'Unknown error', 'woocommerce-paypal-payments' )
), ),
{ icon: NOTIFICATION_ERROR } {
icon: NOTIFICATION_ERROR,
speak: true,
}
); );
} finally { } finally {
setIsRefreshing( false ); setIsRefreshing( false );
@ -76,6 +82,8 @@ const Features = () => {
/> />
} }
contentContainer={ false } contentContainer={ false }
aria-live="polite"
aria-busy={ isRefreshing }
> >
<ContentWrapper> <ContentWrapper>
{ features.map( ( { id, enabled, ...feature } ) => ( { features.map( ( { id, enabled, ...feature } ) => (

View file

@ -33,7 +33,10 @@ const Todos = () => {
'Dismissed items restored successfully.', 'Dismissed items restored successfully.',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'
), ),
{ icon: NOTIFICATION_SUCCESS } {
icon: NOTIFICATION_SUCCESS,
speak: true,
}
); );
} finally { } finally {
setIsResetting( false ); setIsResetting( false );

View file

@ -1,3 +1,4 @@
import { __ } from '@wordpress/i18n';
import Todos from '../Components/Overview/Todos/Todos'; import Todos from '../Components/Overview/Todos/Todos';
import Features from '../Components/Overview/Features/Features'; import Features from '../Components/Overview/Features/Features';
import Help from '../Components/Overview/Help/Help'; import Help from '../Components/Overview/Help/Help';
@ -14,11 +15,26 @@ const TabOverview = () => {
usePaymentGatewaySync(); usePaymentGatewaySync();
if ( ! areTodosReady || ! merchantIsReady || ! featuresIsReady ) { if ( ! areTodosReady || ! merchantIsReady || ! featuresIsReady ) {
return <SpinnerOverlay asModal={ true } />; return (
<SpinnerOverlay
asModal={ true }
ariaLabel={ __(
'Loading PayPal settings',
'woocommerce-paypal-payments'
) }
/>
);
} }
return ( return (
<div className="ppcp-r-tab-overview"> <div
className="ppcp-r-tab-overview"
role="region"
aria-label={ __(
'PayPal Overview',
'woocommerce-paypal-payments'
) }
>
<Todos /> <Todos />
<Features /> <Features />
<Help /> <Help />

View file

@ -162,10 +162,15 @@ export const useNavigationState = () => {
}; };
}; };
export const useDetermineProducts = () => { export const useDetermineProducts = ( ownBrandOnly ) => {
return useSelect( ( select ) => { return useSelect(
return select( STORE_NAME ).determineProductsAndCaps(); ( select ) => {
}, [] ); return select( STORE_NAME ).determineProductsAndCaps(
ownBrandOnly
);
},
[ ownBrandOnly ]
);
}; };
export const useFlags = () => { export const useFlags = () => {

View file

@ -33,10 +33,11 @@ export const flags = ( state ) => {
* This selector does not return state-values, but uses the state to derive the products-array * This selector does not return state-values, but uses the state to derive the products-array
* that should be returned. * that should be returned.
* *
* @param {{}} state * @param {{}} state
* @param {boolean} ownBrandOnly
* @return {{products:string[], options:{}}} The ISU products, based on choices made in the onboarding wizard. * @return {{products:string[], options:{}}} The ISU products, based on choices made in the onboarding wizard.
*/ */
export const determineProductsAndCaps = ( state ) => { export const determineProductsAndCaps = ( state, ownBrandOnly ) => {
/** /**
* An array of product-names that are used to build an onboarding URL via the * An array of product-names that are used to build an onboarding URL via the
* PartnerReferrals API. To avoid confusion with the "products" property from the * PartnerReferrals API. To avoid confusion with the "products" property from the
@ -58,8 +59,12 @@ export const determineProductsAndCaps = ( state ) => {
const { isCasualSeller, areOptionalPaymentMethodsEnabled, products } = const { isCasualSeller, areOptionalPaymentMethodsEnabled, products } =
persistentData( state ); persistentData( state );
const { canUseVaulting, canUseCardPayments } = flags( state ); const { canUseVaulting, canUseCardPayments } = flags( state );
const isBrandedCasualSeller = isCasualSeller && ownBrandOnly;
const cardPaymentsEligibleAndSelected = const cardPaymentsEligibleAndSelected =
canUseCardPayments && areOptionalPaymentMethodsEnabled; canUseCardPayments &&
areOptionalPaymentMethodsEnabled &&
! isBrandedCasualSeller;
if ( ! cardPaymentsEligibleAndSelected ) { if ( ! cardPaymentsEligibleAndSelected ) {
/** /**

View file

@ -31,7 +31,9 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
const { onboardingUrl } = isSandbox const { onboardingUrl } = isSandbox
? CommonHooks.useSandbox() ? CommonHooks.useSandbox()
: CommonHooks.useProduction(); : CommonHooks.useProduction();
const { products, options } = OnboardingHooks.useDetermineProducts(); const { ownBrandOnly } = CommonHooks.useWooSettings();
const { products, options } =
OnboardingHooks.useDetermineProducts( ownBrandOnly );
const { startActivity } = CommonHooks.useBusyState(); const { startActivity } = CommonHooks.useBusyState();
const { authenticateWithOAuth } = CommonHooks.useAuthentication(); const { authenticateWithOAuth } = CommonHooks.useAuthentication();
const [ onboardingUrlState, setOnboardingUrl ] = useState( '' ); const [ onboardingUrlState, setOnboardingUrl ] = useState( '' );

View file

@ -51,6 +51,7 @@ use WooCommerce\PayPalCommerce\Settings\Service\BrandedExperience\PathRepository
use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator; use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator;
use WooCommerce\PayPalCommerce\Settings\Service\FeaturesEligibilityService; use WooCommerce\PayPalCommerce\Settings\Service\FeaturesEligibilityService;
use WooCommerce\PayPalCommerce\Settings\Service\GatewayRedirectService; use WooCommerce\PayPalCommerce\Settings\Service\GatewayRedirectService;
use WooCommerce\PayPalCommerce\Settings\Service\LoadingScreenService;
use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager; use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager;
use WooCommerce\PayPalCommerce\Settings\Service\TodosEligibilityService; use WooCommerce\PayPalCommerce\Settings\Service\TodosEligibilityService;
use WooCommerce\PayPalCommerce\Settings\Service\TodosSortingAndFilteringService; use WooCommerce\PayPalCommerce\Settings\Service\TodosSortingAndFilteringService;
@ -452,15 +453,26 @@ return array(
); );
}, },
'settings.service.merchant_capabilities' => static function ( ContainerInterface $container ) : array { 'settings.service.merchant_capabilities' => static function ( ContainerInterface $container ) : array {
/**
* Use the REST API filter to collect eligibility flags.
*
* TODO: We should switch to using the new `*.eligibility.check` services, which return a callback instead of a boolean.
* Problem with booleans is, that they are evaluated during DI service creation (plugin_loaded), and some relevant filters are not registered at that point.
* Overthink the capability system, it's difficult to reuse across the plugin.
*/
$features = apply_filters( $features = apply_filters(
'woocommerce_paypal_payments_rest_common_merchant_features', 'woocommerce_paypal_payments_rest_common_merchant_features',
array() array()
); );
// TODO: This condition included in the `*.eligibility.check` services; it can be removed when we switch to those services.
$general_settings = $container->get( 'settings.data.general' );
assert( $general_settings instanceof GeneralSettings );
return array( return array(
'apple_pay' => $features['apple_pay']['enabled'] ?? false, 'apple_pay' => ( $features['apple_pay']['enabled'] ?? false ) && ! $general_settings->own_brand_only(),
'google_pay' => $features['google_pay']['enabled'] ?? false, 'google_pay' => ( $features['google_pay']['enabled'] ?? false ) && ! $general_settings->own_brand_only(),
'acdc' => $features['advanced_credit_and_debit_cards']['enabled'] ?? false, 'acdc' => ( $features['advanced_credit_and_debit_cards']['enabled'] ?? false ) && ! $general_settings->own_brand_only(),
'save_paypal' => $features['save_paypal_and_venmo']['enabled'] ?? false, 'save_paypal' => $features['save_paypal_and_venmo']['enabled'] ?? false,
'apm' => $features['alternative_payment_methods']['enabled'] ?? false, 'apm' => $features['alternative_payment_methods']['enabled'] ?? false,
'paylater' => $features['pay_later_messaging']['enabled'] ?? false, 'paylater' => $features['pay_later_messaging']['enabled'] ?? false,
@ -474,6 +486,8 @@ return array(
$button_locations = $container->get( 'settings.service.button_locations' ); $button_locations = $container->get( 'settings.service.button_locations' );
$gateways = $container->get( 'settings.service.gateways_status' ); $gateways = $container->get( 'settings.service.gateways_status' );
// TODO: This "merchant_capabilities" service is only used here. Could it be merged to make the code cleaner and less segmented?
$capabilities = $container->get( 'settings.service.merchant_capabilities' ); $capabilities = $container->get( 'settings.service.merchant_capabilities' );
/** /**
@ -514,7 +528,7 @@ return array(
! $button_locations['cart_enabled'], // Add PayPal buttons to cart. ! $button_locations['cart_enabled'], // Add PayPal buttons to cart.
! $button_locations['block_checkout_enabled'], // Add PayPal buttons to block checkout. ! $button_locations['block_checkout_enabled'], // Add PayPal buttons to block checkout.
! $button_locations['product_enabled'], // Add PayPal buttons to product. ! $button_locations['product_enabled'], // Add PayPal buttons to product.
$capabilities['apple_pay'], // Register Domain for Apple Pay. $container->get( 'applepay.eligible' ) && $capabilities['apple_pay'], // Register Domain for Apple Pay.
$capabilities['acdc'] && ! ( $capabilities['apple_pay'] && $capabilities['google_pay'] ), // Add digital wallets to your account. $capabilities['acdc'] && ! ( $capabilities['apple_pay'] && $capabilities['google_pay'] ), // Add digital wallets to your account.
$container->get( 'applepay.eligible' ) && $capabilities['acdc'] && ! $capabilities['apple_pay'], // Add Apple Pay to your account. $container->get( 'applepay.eligible' ) && $capabilities['acdc'] && ! $capabilities['apple_pay'], // Add Apple Pay to your account.
$container->get( 'googlepay.eligible' ) && $capabilities['acdc'] && ! $capabilities['google_pay'], // Add Google Pay to your account. $container->get( 'googlepay.eligible' ) && $capabilities['acdc'] && ! $capabilities['google_pay'], // Add Google Pay to your account.
@ -591,6 +605,9 @@ return array(
'settings.service.gateway-redirect' => static function (): GatewayRedirectService { 'settings.service.gateway-redirect' => static function (): GatewayRedirectService {
return new GatewayRedirectService(); return new GatewayRedirectService();
}, },
'settings.services.loading-screen-service' => static function ( ContainerInterface $container ) : LoadingScreenService {
return new LoadingScreenService();
},
/** /**
* Returns a list of all payment gateway IDs created by this plugin. * Returns a list of all payment gateway IDs created by this plugin.
* *

View file

@ -40,6 +40,13 @@ class GeneralSettings extends AbstractDataModel {
*/ */
protected array $woo_settings = array(); protected array $woo_settings = array();
/**
* Contexts in which the installation path can be reset.
*/
private const ALLOWED_RESET_REASONS = array(
'plugin_uninstall',
);
/** /**
* Constructor. * Constructor.
* *
@ -82,7 +89,7 @@ class GeneralSettings extends AbstractDataModel {
'seller_type' => 'unknown', 'seller_type' => 'unknown',
// Branded experience installation path. // Branded experience installation path.
'installation_path' => '', 'wc_installation_path' => '',
); );
} }
@ -279,7 +286,7 @@ class GeneralSettings extends AbstractDataModel {
*/ */
public function set_installation_path( string $installation_path ) : void { public function set_installation_path( string $installation_path ) : void {
// The installation path can be set only once. // The installation path can be set only once.
if ( InstallationPathEnum::is_valid( $this->data['installation_path'] ?? '' ) ) { if ( InstallationPathEnum::is_valid( $this->data['wc_installation_path'] ?? '' ) ) {
return; return;
} }
@ -288,7 +295,7 @@ class GeneralSettings extends AbstractDataModel {
return; return;
} }
$this->data['installation_path'] = $installation_path; $this->data['wc_installation_path'] = $installation_path;
} }
/** /**
@ -297,7 +304,23 @@ class GeneralSettings extends AbstractDataModel {
* @return string * @return string
*/ */
public function get_installation_path() : string { public function get_installation_path() : string {
return $this->data['installation_path'] ?? InstallationPathEnum::DIRECT; return $this->data['wc_installation_path'] ?? InstallationPathEnum::DIRECT;
}
/**
* Resets the installation path to empty string. This method should only be called
* during specific circumstances like plugin uninstallation.
*
* @param string $reason The reason for resetting the path, must be an allowed value.
* @return bool Whether the reset was successful.
*/
public function reset_installation_path( string $reason ) : bool {
if ( ! in_array( $reason, self::ALLOWED_RESET_REASONS, true ) ) {
return false;
}
$this->data['wc_installation_path'] = '';
return true;
} }
/** /**

View file

@ -56,7 +56,7 @@ class StylingSettings extends AbstractDataModel {
'cart' => new LocationStylingDTO( 'cart' ), 'cart' => new LocationStylingDTO( 'cart' ),
'classic_checkout' => new LocationStylingDTO( 'classic_checkout' ), 'classic_checkout' => new LocationStylingDTO( 'classic_checkout' ),
'express_checkout' => new LocationStylingDTO( 'express_checkout' ), 'express_checkout' => new LocationStylingDTO( 'express_checkout' ),
'mini_cart' => new LocationStylingDTO( 'mini_cart' ), 'mini_cart' => new LocationStylingDTO( 'mini_cart', false ),
'product' => new LocationStylingDTO( 'product' ), 'product' => new LocationStylingDTO( 'product' ),
); );
} }

View file

@ -67,9 +67,18 @@ class ConnectionListener {
/** /**
* ID of the current user, set by the process() method. * ID of the current user, set by the process() method.
* *
* Default value is 0 (guest), until the real ID is provided to process().
*
* @var int * @var int
*/ */
private int $user_id; private int $user_id = 0;
/**
* The request details (usually the GET data) which were provided.
*
* @var array
*/
private array $request_data = array();
/** /**
* Prepare the instance. * Prepare the instance.
@ -92,9 +101,6 @@ class ConnectionListener {
$this->authentication_manager = $authentication_manager; $this->authentication_manager = $authentication_manager;
$this->redirector = $redirector; $this->redirector = $redirector;
$this->logger = $logger ?: new NullLogger(); $this->logger = $logger ?: new NullLogger();
// Initialize as "guest", the real ID is provided via process().
$this->user_id = 0;
} }
/** /**
@ -106,18 +112,43 @@ class ConnectionListener {
* @throws RuntimeException If the merchant ID does not match the ID previously set via OAuth. * @throws RuntimeException If the merchant ID does not match the ID previously set via OAuth.
*/ */
public function process( int $user_id, array $request ) : void { public function process( int $user_id, array $request ) : void {
$this->user_id = $user_id; $this->user_id = $user_id;
$this->request_data = $request;
if ( ! $this->is_valid_request( $request ) ) { if ( ! $this->is_valid_request() ) {
return;
}
$token = $this->get_token_from_request();
$this->process_oauth_token( $token );
$this->redirect_after_authentication();
}
/**
* Processes the OAuth token from the request.
*
* @param string $token The OAuth token extracted from the request.
* @return void
*/
private function process_oauth_token( string $token ) : void {
// The request contains OAuth details: To avoid abuse we'll slow down the processing.
sleep( 2 );
if ( ! $token ) {
return;
}
if ( $this->was_token_processed( $token ) ) {
return; return;
} }
$token = $this->get_token_from_request( $request );
if ( ! $this->url_manager->validate_token_and_delete( $token, $this->user_id ) ) { if ( ! $this->url_manager->validate_token_and_delete( $token, $this->user_id ) ) {
return; return;
} }
$data = $this->extract_data( $request ); $data = $this->extract_data();
if ( ! $data ) { if ( ! $data ) {
return; return;
} }
@ -126,22 +157,19 @@ class ConnectionListener {
try { try {
$this->authentication_manager->finish_oauth_authentication( $data ); $this->authentication_manager->finish_oauth_authentication( $data );
$this->mark_token_as_processed( $token );
} catch ( \Exception $e ) { } catch ( \Exception $e ) {
$this->logger->error( 'Failed to complete authentication: ' . $e->getMessage() ); $this->logger->error( 'Failed to complete authentication: ' . $e->getMessage() );
} }
$this->redirect_after_authentication();
} }
/** /**
* Determine, if the request details contain connection data that should be * Determine, if the request details contain connection data that should be
* extracted and stored. * extracted and stored.
* *
* @param array $request Request details to verify.
*
* @return bool True, if the request contains valid connection details. * @return bool True, if the request contains valid connection details.
*/ */
private function is_valid_request( array $request ) : bool { private function is_valid_request() : bool {
if ( $this->user_id < 1 || ! $this->settings_page_id ) { if ( $this->user_id < 1 || ! $this->settings_page_id ) {
return false; return false;
} }
@ -157,7 +185,7 @@ class ConnectionListener {
); );
foreach ( $required_params as $param ) { foreach ( $required_params as $param ) {
if ( empty( $request[ $param ] ) ) { if ( empty( $this->request_data[ $param ] ) ) {
return false; return false;
} }
} }
@ -166,19 +194,43 @@ class ConnectionListener {
} }
/** /**
* Extract the merchant details (ID & email) from the request details. * Checks if the provided authentication token is new or has been used before.
* *
* @param array $request The full request details. * This check catches an issue where we receive the same authentication token twice,
* which does not impact the login flow but creates noise in the logs.
*
* @param string $token The authentication token to check.
* @return bool True if the token was already processed.
*/
private function was_token_processed( string $token ) : bool {
$prev_token = get_transient( 'ppcp_previous_auth_token' );
return $prev_token && $prev_token === $token;
}
/**
* Stores the processed authentication token so we can prevent double-processing
* of already verified token.
*
* @param string $token The processed authentication token.
* @return void
*/
private function mark_token_as_processed( string $token ) : void {
set_transient( 'ppcp_previous_auth_token', $token, 60 );
}
/**
* Extract the merchant details (ID & email) from the request details.
* *
* @return array Structured array with 'is_sandbox', 'merchant_id', and 'merchant_email' keys, * @return array Structured array with 'is_sandbox', 'merchant_id', and 'merchant_email' keys,
* or an empty array on failure. * or an empty array on failure.
*/ */
private function extract_data( array $request ) : array { private function extract_data() : array {
$this->logger->info( 'Extracting connection data from request...' ); $this->logger->info( 'Extracting connection data from request...' );
$merchant_id = $this->get_merchant_id_from_request( $request ); $merchant_id = $this->get_merchant_id_from_request( $this->request_data );
$merchant_email = $this->get_merchant_email_from_request( $request ); $merchant_email = $this->get_merchant_email_from_request( $this->request_data );
$seller_type = $this->get_seller_type_from_request( $request ); $seller_type = $this->get_seller_type_from_request( $this->request_data );
if ( ! $merchant_id || ! $merchant_email ) { if ( ! $merchant_id || ! $merchant_email ) {
return array(); return array();
@ -200,17 +252,16 @@ class ConnectionListener {
$redirect_url = $this->get_onboarding_redirect_url(); $redirect_url = $this->get_onboarding_redirect_url();
$this->redirector->redirect( $redirect_url ); $this->redirector->redirect( $redirect_url );
exit;
} }
/** /**
* Returns the sanitized connection token from the incoming request. * Returns the sanitized connection token from the incoming request.
* *
* @param array $request Full request details.
*
* @return string The sanitized token, or an empty string. * @return string The sanitized token, or an empty string.
*/ */
private function get_token_from_request( array $request ) : string { private function get_token_from_request() : string {
return $this->sanitize_string( $request['ppcpToken'] ?? '' ); return $this->sanitize_string( $this->request_data['ppcpToken'] ?? '' );
} }
/** /**

View file

@ -198,8 +198,6 @@ class AuthenticationManager {
* @throws RuntimeException When failed to retrieve payee. * @throws RuntimeException When failed to retrieve payee.
*/ */
public function authenticate_via_direct_api( bool $use_sandbox, string $client_id, string $client_secret ) : void { public function authenticate_via_direct_api( bool $use_sandbox, string $client_id, string $client_secret ) : void {
$this->disconnect();
$this->logger->info( $this->logger->info(
'Attempting manual connection to PayPal...', 'Attempting manual connection to PayPal...',
array( array(
@ -261,8 +259,6 @@ class AuthenticationManager {
* @throws RuntimeException When failed to retrieve payee. * @throws RuntimeException When failed to retrieve payee.
*/ */
public function authenticate_via_oauth( bool $use_sandbox, string $shared_id, string $auth_code ) : void { public function authenticate_via_oauth( bool $use_sandbox, string $shared_id, string $auth_code ) : void {
$this->disconnect();
$this->logger->info( $this->logger->info(
'Attempting OAuth login to PayPal...', 'Attempting OAuth login to PayPal...',
array( array(

View file

@ -85,14 +85,9 @@ class GatewayRedirectService {
// Get current URL parameters. // Get current URL parameters.
// phpcs:disable WordPress.Security.NonceVerification.Recommended // phpcs:disable WordPress.Security.NonceVerification.Recommended
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $page = isset( $_GET['page'] ) ? wc_clean( wp_unslash( $_GET['page'] ) ) : '';
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash $tab = isset( $_GET['tab'] ) ? wc_clean( wp_unslash( $_GET['tab'] ) ) : '';
// The sanitize_get_param method handles unslashing and sanitization internally. $section = isset( $_GET['section'] ) ? wc_clean( wp_unslash( $_GET['section'] ) ) : '';
$page = isset( $_GET['page'] ) ? $this->sanitize_get_param( $_GET['page'] ) : '';
$tab = isset( $_GET['tab'] ) ? $this->sanitize_get_param( $_GET['tab'] ) : '';
$section = isset( $_GET['section'] ) ? $this->sanitize_get_param( $_GET['section'] ) : '';
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.MissingUnslash
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// phpcs:enable WordPress.Security.NonceVerification.Recommended // phpcs:enable WordPress.Security.NonceVerification.Recommended
// Check if we're on a WooCommerce settings page and checkout tab. // Check if we're on a WooCommerce settings page and checkout tab.
@ -113,17 +108,4 @@ class GatewayRedirectService {
exit; exit;
} }
} }
/**
* Sanitizes a GET parameter that could be string or array.
*
* @param mixed $param The parameter to sanitize.
* @return string The sanitized parameter.
*/
private function sanitize_get_param( $param ): string {
if ( is_array( $param ) ) {
return '';
}
return sanitize_text_field( wp_unslash( $param ) );
}
} }

View file

@ -56,7 +56,7 @@ class InternalRestService {
$rest_nonce = wp_create_nonce( 'wp_rest' ); $rest_nonce = wp_create_nonce( 'wp_rest' );
$auth_cookies = $this->build_authentication_cookie(); $auth_cookies = $this->build_authentication_cookie();
$this->logger->info( "Calling internal REST endpoint: $rest_url" ); $this->logger->info( "Calling internal REST endpoint [$rest_url]" );
$response = wp_remote_request( $response = wp_remote_request(
$rest_url, $rest_url,
@ -69,6 +69,7 @@ class InternalRestService {
'cookies' => $auth_cookies, 'cookies' => $auth_cookies,
) )
); );
$this->logger->debug( "Finished internal REST call [$rest_url]" );
if ( is_wp_error( $response ) ) { if ( is_wp_error( $response ) ) {
// Error: The wp_remote_request() call failed (timeout or similar). // Error: The wp_remote_request() call failed (timeout or similar).

View file

@ -0,0 +1,73 @@
<?php
/**
* Provides loading screen handling logic for PayPal settings page.
*
* @package WooCommerce\PayPalCommerce\Settings\Service
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Settings\Service;
/**
* LoadingScreenService class. Handles the display of loading screen for the PayPal settings page.
*/
class LoadingScreenService {
/**
* Register hooks.
*
* @return void
*/
public function register(): void {
if ( ! is_admin() ) {
return;
}
add_action(
'admin_head',
array( $this, 'add_settings_loading_screen' )
);
}
/**
* Add CSS to permanently hide specific WooCommerce elements on the PayPal settings page.
*
* @return void
*/
public function add_settings_loading_screen(): void {
// Only run on the specific WooCommerce PayPal settings page.
if ( ! $this->is_ppcp_settings_page() ) {
return;
}
?>
<style>
/* Permanently hide these WooCommerce elements. */
.woocommerce form#mainform > *:not(#ppcp-settings-container),
#woocommerce-embedded-root {
display: none;
}
#wpcontent #wpbody {
margin-top: 0;
}
</style>
<?php
}
/**
* Check if we're on the PayPal checkout settings page.
*
* @return bool True if we're on the PayPal settings page
*/
private function is_ppcp_settings_page(): bool {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$page = wc_clean( wp_unslash( $_GET['page'] ?? '' ) );
$tab = wc_clean( wp_unslash( $_GET['tab'] ?? '' ) );
$section = wc_clean( wp_unslash( $_GET['section'] ?? '' ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return $page === 'wc-settings' && $tab === 'checkout' && $section === 'ppcp-gateway';
}
}

View file

@ -234,6 +234,9 @@ class SettingsDataManager {
$methods_apm = $this->methods_definition->group_apms(); $methods_apm = $this->methods_definition->group_apms();
$all_methods = array_merge( $methods_paypal, $methods_cards, $methods_apm ); $all_methods = array_merge( $methods_paypal, $methods_cards, $methods_apm );
// Enable the Fastlane watermark by default.
$this->payment_methods->set_fastlane_display_watermark( true );
foreach ( $all_methods as $method ) { foreach ( $all_methods as $method ) {
$this->payment_methods->toggle_method_state( $method['id'], false ); $this->payment_methods->toggle_method_state( $method['id'], false );
} }
@ -330,7 +333,7 @@ class SettingsDataManager {
'cart' => new LocationStylingDTO( 'cart', true, $methods_full ), 'cart' => new LocationStylingDTO( 'cart', true, $methods_full ),
'classic_checkout' => new LocationStylingDTO( 'classic_checkout', true, $methods_full ), 'classic_checkout' => new LocationStylingDTO( 'classic_checkout', true, $methods_full ),
'express_checkout' => new LocationStylingDTO( 'express_checkout', true, $methods_full ), 'express_checkout' => new LocationStylingDTO( 'express_checkout', true, $methods_full ),
'mini_cart' => new LocationStylingDTO( 'mini_cart', true, $methods_full ), 'mini_cart' => new LocationStylingDTO( 'mini_cart', false, $methods_full ),
'product' => new LocationStylingDTO( 'product', true, $methods_own ), 'product' => new LocationStylingDTO( 'product', true, $methods_own ),
); );

View file

@ -10,6 +10,7 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings; namespace WooCommerce\PayPalCommerce\Settings;
use WC_Payment_Gateway; use WC_Payment_Gateway;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PartnerAttribution; use WooCommerce\PayPalCommerce\ApiClient\Helper\PartnerAttribution;
use WooCommerce\PayPalCommerce\Applepay\Assets\AppleProductStatus; use WooCommerce\PayPalCommerce\Applepay\Assets\AppleProductStatus;
@ -25,11 +26,13 @@ use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\P24Gateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\TrustlyGateway; use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\TrustlyGateway;
use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile; use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile;
use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel;
use WooCommerce\PayPalCommerce\Settings\Data\TodosModel; use WooCommerce\PayPalCommerce\Settings\Data\TodosModel;
use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener; use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener;
use WooCommerce\PayPalCommerce\Settings\Service\BrandedExperience\PathRepository; use WooCommerce\PayPalCommerce\Settings\Service\BrandedExperience\PathRepository;
use WooCommerce\PayPalCommerce\Settings\Service\GatewayRedirectService; use WooCommerce\PayPalCommerce\Settings\Service\GatewayRedirectService;
use WooCommerce\PayPalCommerce\Settings\Service\LoadingScreenService;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule;
@ -123,7 +126,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
) )
); );
wp_enqueue_script( 'ppcp-switch-settings-ui', '', array( 'wp-i18n' ), $script_asset_file['version'] ); wp_enqueue_script( 'ppcp-switch-settings-ui', '', array( 'wp-i18n' ), $script_asset_file['version'], false );
wp_set_script_translations( wp_set_script_translations(
'ppcp-switch-settings-ui', 'ppcp-switch-settings-ui',
'woocommerce-paypal-payments', 'woocommerce-paypal-payments',
@ -174,6 +177,11 @@ class SettingsModule implements ServiceModule, ExecutableModule {
} }
); );
// Suppress WooCommerce Settings UI elements via CSS to improve the loading experience.
$loading_screen_service = $container->get( 'settings.services.loading-screen-service' );
assert( $loading_screen_service instanceof LoadingScreenService );
$loading_screen_service->register();
$this->apply_branded_only_limitations( $container ); $this->apply_branded_only_limitations( $container );
add_action( add_action(
@ -205,7 +213,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
true true
); );
wp_enqueue_script( 'ppcp-admin-settings', '', array( 'wp-i18n' ), $script_asset_file['version'] ); wp_enqueue_script( 'ppcp-admin-settings', '', array( 'wp-i18n' ), $script_asset_file['version'], false );
wp_set_script_translations( wp_set_script_translations(
'ppcp-admin-settings', 'ppcp-admin-settings',
'woocommerce-paypal-payments', 'woocommerce-paypal-payments',
@ -281,10 +289,12 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_action( add_action(
'woocommerce_paypal_payments_gateway_admin_options_wrapper', 'woocommerce_paypal_payments_gateway_admin_options_wrapper',
function () : void { function () use ( $container ) : void {
global $hide_save_button; global $hide_save_button;
$hide_save_button = true; $hide_save_button = true;
$this->initialize_branded_only( $container );
$this->render_header(); $this->render_header();
$this->render_content(); $this->render_content();
} }
@ -329,6 +339,10 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_action( add_action(
'woocommerce_paypal_payments_merchant_disconnected', 'woocommerce_paypal_payments_merchant_disconnected',
static function () use ( $container ) : void { static function () use ( $container ) : void {
$logger = $container->get( 'woocommerce.logger.woocommerce' );
assert( $logger instanceof LoggerInterface );
$logger->info( 'Merchant disconnected, reset onboarding' );
// Reset onboarding profile. // Reset onboarding profile.
$onboarding_profile = $container->get( 'settings.data.onboarding' ); $onboarding_profile = $container->get( 'settings.data.onboarding' );
assert( $onboarding_profile instanceof OnboardingProfile ); assert( $onboarding_profile instanceof OnboardingProfile );
@ -350,6 +364,10 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_action( add_action(
'woocommerce_paypal_payments_authenticated_merchant', 'woocommerce_paypal_payments_authenticated_merchant',
static function () use ( $container ) : void { static function () use ( $container ) : void {
$logger = $container->get( 'woocommerce.logger.woocommerce' );
assert( $logger instanceof LoggerInterface );
$logger->info( 'Merchant connected, complete onboarding and set defaults.' );
$onboarding_profile = $container->get( 'settings.data.onboarding' ); $onboarding_profile = $container->get( 'settings.data.onboarding' );
assert( $onboarding_profile instanceof OnboardingProfile ); assert( $onboarding_profile instanceof OnboardingProfile );
@ -403,18 +421,18 @@ class SettingsModule implements ServiceModule, ExecutableModule {
unset( $payment_methods[ CardButtonGateway::ID ] ); unset( $payment_methods[ CardButtonGateway::ID ] );
} else { } else {
// For non-ACDC regions unset ACDC, local APMs and set BCDC. // For non-ACDC regions unset ACDC, local APMs and set BCDC.
unset( $payment_methods[ CreditCardGateway::ID ] ); unset( $payment_methods[ CreditCardGateway::ID ] );
unset( $payment_methods['pay-later'] ); unset( $payment_methods['pay-later'] );
unset( $payment_methods[ BancontactGateway::ID ] ); unset( $payment_methods[ BancontactGateway::ID ] );
unset( $payment_methods[ BlikGateway::ID ] ); unset( $payment_methods[ BlikGateway::ID ] );
unset( $payment_methods[ EPSGateway::ID ] ); unset( $payment_methods[ EPSGateway::ID ] );
unset( $payment_methods[ IDealGateway::ID ] ); unset( $payment_methods[ IDealGateway::ID ] );
unset( $payment_methods[ MyBankGateway::ID ] ); unset( $payment_methods[ MyBankGateway::ID ] );
unset( $payment_methods[ P24Gateway::ID ] ); unset( $payment_methods[ P24Gateway::ID ] );
unset( $payment_methods[ TrustlyGateway::ID ] ); unset( $payment_methods[ TrustlyGateway::ID ] );
unset( $payment_methods[ MultibancoGateway::ID ] ); unset( $payment_methods[ MultibancoGateway::ID ] );
unset( $payment_methods[ PayUponInvoiceGateway::ID ] ); unset( $payment_methods[ PayUponInvoiceGateway::ID ] );
unset( $payment_methods[ OXXO::ID ] ); unset( $payment_methods[ OXXO::ID ] );
} }
// Unset Venmo when store location is not United States. // Unset Venmo when store location is not United States.
@ -481,34 +499,42 @@ class SettingsModule implements ServiceModule, ExecutableModule {
$methods[] = $applepay_gateway; $methods[] = $applepay_gateway;
$methods[] = $axo_gateway; $methods[] = $axo_gateway;
$is_payments_page = $container->get( 'wcgateway.is-wc-payments-page' );
$all_gateway_ids = $container->get( 'settings.config.all-gateway-ids' );
if ( $is_payments_page ) {
$methods = array_filter(
$methods,
function ( $method ) use ( $all_gateway_ids ): bool {
if ( ! is_object( $method )
|| $method->id === PayPalGateway::ID
|| ! in_array( $method->id, $all_gateway_ids, true )
) {
return true;
}
if ( ! $this->is_gateway_enabled( $method->id ) ) {
return false;
}
return true;
}
);
}
return $methods; return $methods;
}, },
99 99
); );
/**
* Filters the available payment gateways in the WooCommerce admin settings.
*
* Ensures that only enabled PayPal payment gateways are displayed.
*
* @hook woocommerce_admin_field_payment_gateways
* @priority 5 Allows modifying the registered gateways before they are displayed.
*/
add_action(
'woocommerce_admin_field_payment_gateways',
function () use ( $container ) : void {
$all_gateway_ids = $container->get( 'settings.config.all-gateway-ids' );
$payment_gateways = WC()->payment_gateways->payment_gateways;
foreach ( $payment_gateways as $index => $payment_gateway ) {
$payment_gateway_id = $payment_gateway->id;
if (
! in_array( $payment_gateway_id, $all_gateway_ids, true )
|| $payment_gateway_id === PayPalGateway::ID
|| $this->is_gateway_enabled( $payment_gateway_id )
) {
continue;
}
unset( WC()->payment_gateways->payment_gateways[ $index ] );
}
},
5
);
// Remove the Fastlane gateway if the customer is logged in, ensuring that we don't interfere with the Fastlane gateway status in the settings UI. // Remove the Fastlane gateway if the customer is logged in, ensuring that we don't interfere with the Fastlane gateway status in the settings UI.
add_filter( add_filter(
'woocommerce_available_payment_gateways', 'woocommerce_available_payment_gateways',
@ -611,7 +637,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
// Enable Fastlane after onboarding if the store is compatible. // Enable Fastlane after onboarding if the store is compatible.
add_action( add_action(
'woocommerce_paypal_payments_toggle_payment_gateways', 'woocommerce_paypal_payments_toggle_payment_gateways',
function( PaymentSettings $payment_methods, ConfigurationFlagsDTO $flags ) use ( $container ) { function ( PaymentSettings $payment_methods, ConfigurationFlagsDTO $flags ) use ( $container ) {
if ( $flags->is_business_seller && $flags->use_card_payments ) { if ( $flags->is_business_seller && $flags->use_card_payments ) {
$compatibility_checker = $container->get( 'axo.helpers.compatibility-checker' ); $compatibility_checker = $container->get( 'axo.helpers.compatibility-checker' );
assert( $compatibility_checker instanceof CompatibilityChecker ); assert( $compatibility_checker instanceof CompatibilityChecker );
@ -628,7 +654,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
// Toggle payment gateways after onboarding based on flags. // Toggle payment gateways after onboarding based on flags.
add_action( add_action(
'woocommerce_paypal_payments_sync_gateways', 'woocommerce_paypal_payments_sync_gateways',
static function() use ( $container ) { static function () use ( $container ) {
$settings_data_manager = $container->get( 'settings.service.data-manager' ); $settings_data_manager = $container->get( 'settings.service.data-manager' );
assert( $settings_data_manager instanceof SettingsDataManager ); assert( $settings_data_manager instanceof SettingsDataManager );
$settings_data_manager->sync_gateway_settings(); $settings_data_manager->sync_gateway_settings();
@ -640,6 +666,17 @@ class SettingsModule implements ServiceModule, ExecutableModule {
assert( $gateway_redirect_service instanceof GatewayRedirectService ); assert( $gateway_redirect_service instanceof GatewayRedirectService );
$gateway_redirect_service->register(); $gateway_redirect_service->register();
// Do not render Pay Later messaging if the "Save PayPal and Venmo" setting is enabled.
add_filter(
'woocommerce_paypal_payments_should_render_pay_later_messaging',
static function() use ( $container ): bool {
$settings_model = $container->get( 'settings.data.settings' );
assert( $settings_model instanceof SettingsModel );
return ! $settings_model->get_save_paypal_and_venmo();
}
);
return true; return true;
} }
@ -667,6 +704,36 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_filter( 'woocommerce_paypal_payments_is_eligible_for_card_fields', '__return_false' ); add_filter( 'woocommerce_paypal_payments_is_eligible_for_card_fields', '__return_false' );
} }
/**
* Initializes the branded-only flags if they are not set.
*
* This method can be called multiple times:
* The flags are only initialized once but does not change afterward.
*
* Also, this check has no impact on performance for two reasons:
* 1. The GeneralSettings class is already initialized and will short-circuit
* the check if the settings are already initialized.
* 2. The settings UI is a React app, this method only runs when the React app
* is injected to the DOM, and not while the UI is used.
*
* @param ContainerInterface $container The DI container provider.
* @return void
*/
protected function initialize_branded_only( ContainerInterface $container ) : void {
$path_repository = $container->get( 'settings.service.branded-experience.path-repository' );
assert( $path_repository instanceof PathRepository );
$partner_attribution = $container->get( 'api.helper.partner-attribution' );
assert( $partner_attribution instanceof PartnerAttribution );
$general_settings = $container->get( 'settings.data.general' );
assert( $general_settings instanceof GeneralSettings );
$path_repository->persist();
$partner_attribution->initialize_bn_code( $general_settings->get_installation_path() );
}
/** /**
* Outputs the settings page header (title and back-link). * Outputs the settings page header (title and back-link).
* *
@ -693,7 +760,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
* @param string $gateway_name The gateway name. * @param string $gateway_name The gateway name.
* @return bool True if the payment gateway with the given name is enabled, otherwise false. * @return bool True if the payment gateway with the given name is enabled, otherwise false.
*/ */
protected function is_gateway_enabled( string $gateway_name ): bool { protected function is_gateway_enabled( string $gateway_name ) : bool {
$gateway_settings = get_option( "woocommerce_{$gateway_name}_settings", array() ); $gateway_settings = get_option( "woocommerce_{$gateway_name}_settings", array() );
$gateway_enabled = $gateway_settings['enabled'] ?? false; $gateway_enabled = $gateway_settings['enabled'] ?? false;

View file

@ -49,7 +49,11 @@ class SubscriptionHelper {
return false; return false;
} }
$cart = WC()->cart; $cart = WC()->cart;
if ( ! $cart || $cart->is_empty() ) { /**
* Don't use `$cart->is_empty()` for checking for an empty cart.
* This is maybe called so early that it can corrupt it because it loads it than from session
*/
if ( ! $cart || empty( $cart->cart_contents ) ) {
return false; return false;
} }

View file

@ -8,7 +8,7 @@
* @package WooCommerce\WooCommerce\Logging\Logger * @package WooCommerce\WooCommerce\Logging\Logger
*/ */
declare(strict_types=1); declare( strict_types = 1 );
namespace WooCommerce\WooCommerce\Logging\Logger; namespace WooCommerce\WooCommerce\Logging\Logger;
@ -20,7 +20,6 @@ use Psr\Log\LoggerTrait;
*/ */
class WooCommerceLogger implements LoggerInterface { class WooCommerceLogger implements LoggerInterface {
use LoggerTrait; use LoggerTrait;
/** /**
@ -35,23 +34,48 @@ class WooCommerceLogger implements LoggerInterface {
* *
* @var string The source. * @var string The source.
*/ */
private $source; private string $source;
/**
* Details that are output before the first real log message, to help
* identify the request.
*
* @var string
*/
private string $request_info;
/**
* A random prefix which is visible in every log message, to better
* understand which messages belong to the same request.
*
* @var string
*/
private string $prefix;
/** /**
* WooCommerceLogger constructor. * WooCommerceLogger constructor.
* *
* @param \WC_Logger_Interface $wc_logger The WooCommerce logger. * @param \WC_Logger_Interface $wc_logger The WooCommerce logger.
* @param string $source The source. * @param string $source The source.
*/ */
public function __construct( \WC_Logger_Interface $wc_logger, string $source ) { public function __construct( \WC_Logger_Interface $wc_logger, string $source ) {
$this->wc_logger = $wc_logger; $this->wc_logger = $wc_logger;
$this->source = $source; $this->source = $source;
$this->prefix = sprintf( '#%s - ', wp_rand( 1000, 9999 ) );
// phpcs:disable -- Intentionally not sanitized, for logging purposes.
$method = wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'CLI' );
$request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ?? '-' );
// phpcs:enable
$request_path = wp_parse_url( $request_uri, PHP_URL_PATH );
$this->request_info = "$method $request_path";
} }
/** /**
* Logs a message. * Logs a message.
* *
* @param mixed $level The logging level. * @param mixed $level The logging level.
* @param string $message The message. * @param string $message The message.
* @param array $context The context. * @param array $context The context.
*/ */
@ -59,6 +83,16 @@ class WooCommerceLogger implements LoggerInterface {
if ( ! isset( $context['source'] ) ) { if ( ! isset( $context['source'] ) ) {
$context['source'] = $this->source; $context['source'] = $this->source;
} }
$this->wc_logger->log( $level, $message, $context );
if ( $this->request_info ) {
$this->wc_logger->log(
'debug',
"{$this->prefix}[New Request] $this->request_info",
array( 'source' => $context['source'] )
);
$this->request_info = '';
}
$this->wc_logger->log( $level, "{$this->prefix}$message", $context );
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "woocommerce-paypal-payments", "name": "woocommerce-paypal-payments",
"version": "3.0.1", "version": "3.0.4",
"description": "WooCommerce PayPal Payments", "description": "WooCommerce PayPal Payments",
"repository": "https://github.com/woocommerce/woocommerce-paypal-payments", "repository": "https://github.com/woocommerce/woocommerce-paypal-payments",
"license": "GPL-2.0", "license": "GPL-2.0",

View file

@ -2,9 +2,9 @@
Contributors: paypal, woocommerce, automattic, syde Contributors: paypal, woocommerce, automattic, syde
Tags: woocommerce, paypal, payments, ecommerce, credit card Tags: woocommerce, paypal, payments, ecommerce, credit card
Requires at least: 6.5 Requires at least: 6.5
Tested up to: 6.7 Tested up to: 6.8
Requires PHP: 7.4 Requires PHP: 7.4
Stable tag: 3.0.1 Stable tag: 3.0.4
License: GPLv2 License: GPLv2
License URI: http://www.gnu.org/licenses/gpl-2.0.html License URI: http://www.gnu.org/licenses/gpl-2.0.html
@ -156,6 +156,28 @@ If you encounter issues with the PayPal buttons not appearing after an update, p
== Changelog == == Changelog ==
= 3.0.4 - xxxx-xx-xx =
* Fix - Onboarding screen blank when WooPayments plugin is active #3312
= 3.0.3 - 2025-04-08 =
* Fix - BN code was set before the installation path was initialized #3309
* Fix - Things to do next referenced Apple Pay while in branded-only mode #3308
* Fix - Disabled payment methods were not hidden in reactified WooCommerce Payments settings tab #3290
= 3.0.2 - 2025-04-03 =
* Enhancement - Check the branded-only flag when settings-UI is loaded the first time #3278
* Enhancement - Implement a Cache-Flush API #3276
* Enhancement - Disable the mini-cart location by default #3284
* Enhancement - Remove branded-only flag when uninstalling PayPal Payments #3295
* Fix - Welcome screen lists "all major credit/debit cards, Apple Pay, Google Pay," in branded-only mode #3281
* Fix - Correct heading in onboarding step 4 in branded-only mode #3282
* Fix - Hide the payment methods screen for personal user in branded-only mode #3286
* Fix - Enabling Save PayPal does not disable Pay Later messaging #3288
* Fix - Settings UI: Fix Feature button links #3285
* Fix - Create mapping for the 3d_secure_contingency setting #3262
* Fix - Enable Fastlane Watermark by default in new settings UI #3296
* Fix - Payment method screen is referencing credit cards, digital wallets in branded-only mode #3297
= 3.0.1 - 2025-03-26 = = 3.0.1 - 2025-03-26 =
* Enhancement - Include Fastlane meta on homepage #3151 * Enhancement - Include Fastlane meta on homepage #3151
* Enhancement - Include Branded-only plugin configuration for certain installation paths * Enhancement - Include Branded-only plugin configuration for certain installation paths

View file

@ -5,28 +5,30 @@
* @package WooCommerce\PayPalCommerce * @package WooCommerce\PayPalCommerce
*/ */
declare(strict_types=1); declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce; namespace WooCommerce\PayPalCommerce;
use WooCommerce\PayPalCommerce\Uninstall\ClearDatabaseInterface; use WooCommerce\PayPalCommerce\Uninstall\ClearDatabaseInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\NotFoundExceptionInterface;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings;
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
die( 'Direct access not allowed.' ); die( 'Direct access not allowed.' );
} }
$root_dir = __DIR__; $root_dir = __DIR__;
$main_plugin_file = "{$root_dir}/woocommerce-paypal-payments.php"; $main_plugin_file = "{$root_dir}/woocommerce-paypal-payments.php";
if ( !file_exists( $main_plugin_file ) ) { if ( ! file_exists( $main_plugin_file ) ) {
return; return;
} }
require $main_plugin_file; require $main_plugin_file;
( static function (string $root_dir): void { ( static function ( string $root_dir ) : void {
$autoload_filepath = "{$root_dir}/vendor/autoload.php"; $autoload_filepath = "{$root_dir}/vendor/autoload.php";
if ( file_exists( $autoload_filepath ) && ! class_exists( '\WooCommerce\PayPalCommerce\PluginModule' ) ) { if ( file_exists( $autoload_filepath ) && ! class_exists( '\WooCommerce\PayPalCommerce\PluginModule' ) ) {
@ -39,9 +41,12 @@ require $main_plugin_file;
$app_container = $bootstrap( $root_dir ); $app_container = $bootstrap( $root_dir );
assert( $app_container instanceof ContainerInterface ); assert( $app_container instanceof ContainerInterface );
clear_plugin_branding( $app_container );
$settings = $app_container->get( 'wcgateway.settings' ); $settings = $app_container->get( 'wcgateway.settings' );
assert( $settings instanceof Settings ); assert( $settings instanceof Settings );
// TODO: This is a flag only present in the #legacy-ui. Should we change this to a filter, or remove the DB reset code?
$should_clear_db = $settings->has( 'uninstall_clear_db_on_uninstall' ) && $settings->get( 'uninstall_clear_db_on_uninstall' ); $should_clear_db = $settings->has( 'uninstall_clear_db_on_uninstall' ) && $settings->get( 'uninstall_clear_db_on_uninstall' );
if ( ! $should_clear_db ) { if ( ! $should_clear_db ) {
return; return;
@ -74,4 +79,33 @@ require $main_plugin_file;
} }
); );
} }
} )($root_dir); } )( $root_dir );
/**
* Clears plugin branding by resetting the installation path flag.
*
* @param ContainerInterface $container The plugin's DI container.
* @return void
*/
function clear_plugin_branding( ContainerInterface $container ) : void {
/*
* This flag is set by WooCommerce when the plugin is installed via their
* Settings page. We remove it here, as uninstalling the plugin should
* open up the possibility of installing it from a different source in
* "white label" mode.
*/
delete_option( 'woocommerce_paypal_branded' );
try {
$general_settings = $container->get( 'settings.data.general' );
assert( $general_settings instanceof GeneralSettings );
if ( $general_settings->reset_installation_path( 'plugin_uninstall' ) ) {
$general_settings->save();
}
} catch ( NotFoundExceptionInterface $e ) {
// The container does not exist or did not return a GeneralSettings instance.
// In any case: A failure can be ignored, as it means we cannot reset anything.
return;
}
}

View file

@ -3,7 +3,7 @@
* Plugin Name: WooCommerce PayPal Payments * Plugin Name: WooCommerce PayPal Payments
* Plugin URI: https://woocommerce.com/products/woocommerce-paypal-payments/ * Plugin URI: https://woocommerce.com/products/woocommerce-paypal-payments/
* Description: PayPal's latest complete payments processing solution. Accept PayPal, Pay Later, credit/debit cards, alternative digital wallets local payment types and bank accounts. Turn on only PayPal options or process a full suite of payment methods. Enable global transaction with extensive currency and country coverage. * Description: PayPal's latest complete payments processing solution. Accept PayPal, Pay Later, credit/debit cards, alternative digital wallets local payment types and bank accounts. Turn on only PayPal options or process a full suite of payment methods. Enable global transaction with extensive currency and country coverage.
* Version: 3.0.1 * Version: 3.0.4
* Author: PayPal * Author: PayPal
* Author URI: https://paypal.com/ * Author URI: https://paypal.com/
* License: GPL-2.0 * License: GPL-2.0
@ -27,7 +27,7 @@ define( 'PAYPAL_API_URL', 'https://api-m.paypal.com' );
define( 'PAYPAL_URL', 'https://www.paypal.com' ); define( 'PAYPAL_URL', 'https://www.paypal.com' );
define( 'PAYPAL_SANDBOX_API_URL', 'https://api-m.sandbox.paypal.com' ); define( 'PAYPAL_SANDBOX_API_URL', 'https://api-m.sandbox.paypal.com' );
define( 'PAYPAL_SANDBOX_URL', 'https://www.sandbox.paypal.com' ); define( 'PAYPAL_SANDBOX_URL', 'https://www.sandbox.paypal.com' );
define( 'PAYPAL_INTEGRATION_DATE', '2025-03-25' ); define( 'PAYPAL_INTEGRATION_DATE', '2025-04-23' );
define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' );
! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' ); ! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' );