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 ***
= 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 =
* Enhancement - Include Fastlane meta on homepage #3151
* 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\Authentication\PayPalBearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken;
use Psr\Log\LoggerInterface;
/**
* Class ApiModule
@ -113,23 +114,20 @@ class ApiModule implements ServiceModule, ExtendingModule, ExecutableModule {
'woocommerce_paypal_payments_flush_api_cache',
static function () use ( $c ) {
$caches = array(
'api.paypal-bearer-cache' => array(
PayPalBearer::CACHE_KEY,
),
'api.client-credentials-cache' => array(
SdkClientToken::CACHE_KEY,
),
'api.paypal-bearer-cache',
'api.client-credentials-cache',
'settings.service.signup-link-cache',
);
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 );
assert( $cache instanceof Cache );
foreach ( $keys as $key ) {
if ( $cache->has( $key ) ) {
$cache->delete( $key );
}
}
$cache->flush();
}
}
);

View file

@ -5,7 +5,7 @@
* @package WooCommerce\PayPalCommerce\ApiClient\Helper
*/
declare( strict_types=1 );
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\ApiClient\Helper;
@ -48,8 +48,9 @@ class Cache {
*
* @return bool
*/
public function has( string $key ): bool {
public function has( string $key ) : bool {
$value = $this->get( $key );
return false !== $value;
}
@ -58,7 +59,7 @@ class Cache {
*
* @param string $key The key.
*/
public function delete( string $key ): void {
public function delete( string $key ) : void {
delete_transient( $this->prefix . $key );
}
@ -71,7 +72,34 @@ class Cache {
*
* @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 );
}
/**
* 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
.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,
scale $transition-duration ease-in,
display $transition-duration ease-in;

View file

@ -10,7 +10,7 @@ import { STORE_NAME } from '../stores/axoStore';
*/
export const setupAuthenticationClassToggle = () => {
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 updateAuthenticationClass = () => {

View file

@ -181,8 +181,6 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
add_action(
'wp_loaded',
function () use ( $c ) {
$module = $this;
$this->session_handler = $c->get( 'session.handler' );
$settings = $c->get( 'wcgateway.settings' );
@ -208,12 +206,12 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
// Enqueue frontend scripts.
add_action(
'wp_enqueue_scripts',
static function () use ( $c, $manager, $module ) {
function () use ( $c, $manager ) {
$smart_button = $c->get( 'button.smart-button' );
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();
}
}
@ -222,8 +220,8 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
// Render submit button.
add_action(
$manager->checkout_button_renderer_hook(),
static function () use ( $c, $manager, $module ) {
if ( $module->should_render_fastlane( $c ) ) {
function () use ( $c, $manager ) {
if ( $this->should_render_fastlane( $c ) ) {
$manager->render_checkout_button();
}
}
@ -278,14 +276,14 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
add_filter(
'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' );
assert( $api instanceof SdkClientToken );
$logger = $c->get( 'woocommerce.logger.woocommerce' );
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;
}
@ -403,6 +421,7 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
* @return bool
*/
private function should_render_fastlane( ContainerInterface $c ): bool {
$dcc_configuration = $c->get( 'wcgateway.configuration.card-configuration' );
assert( $dcc_configuration instanceof CardPaymentsConfiguration );

View file

@ -466,6 +466,10 @@ class SmartButton implements SmartButtonInterface {
* @return 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() ) {
return false;
}

View file

@ -10,6 +10,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Compat\Settings;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Settings\Data\AbstractDataModel;
use WooCommerce\PayPalCommerce\Settings\Data\PaymentSettings;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
/**
@ -20,6 +22,15 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
*/
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.
*
@ -29,6 +40,7 @@ class PaymentMethodSettingsMapHelper {
return array(
'dcc_enabled' => CreditCardGateway::ID,
'axo_enabled' => AxoGateway::ID,
'3d_secure_contingency' => 'three_d_secure',
);
}
@ -36,10 +48,21 @@ class PaymentMethodSettingsMapHelper {
* Retrieves the value of a mapped key from the new 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).
*/
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;
}
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;
default:
$payment_method = $this->map()[ $old_key ] ?? false;
if ( ! $payment_method ) {
@ -48,6 +71,7 @@ class PaymentMethodSettingsMapHelper {
return $this->is_gateway_enabled( $payment_method );
}
}
/**
* Checks if the payment gateway with the given name is enabled.

View file

@ -214,7 +214,7 @@ class SettingsMapHelper {
: $this->settings_tab_map_helper->mapped_value( $old_key, $this->model_cache[ $model_id ] );
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:
return $this->model_cache[ $model_id ][ $new_key ] ?? null;

View file

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

View file

@ -581,6 +581,10 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu
),
),
'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 {
height: 400px;
width: 400px;
position: absolute;
left: 50%;
transform: translate(-50%, 0);
text-align: center;
.ppcp-r-spinner-overlay {
display: flex;
flex-direction: column;

View file

@ -70,6 +70,15 @@ button.components-button, a.components-button {
--button-disabled-color: #{$color-gray-100};
--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 {
@ -86,7 +95,7 @@ button.components-button, a.components-button {
--button-color: #{$color-blueberry};
--button-hover-color: #{$color-gradient-dark};
&:focus:not(:disabled) {
&:focus-visible:not(:disabled) {
border: none;
box-shadow: none;
}
@ -95,6 +104,16 @@ button.components-button, a.components-button {
&.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 {

View file

@ -111,12 +111,7 @@
margin: 0;
}
}
// Custom styles.
.components-form-toggle.is-checked > .components-form-toggle__track {
background-color: $color-blueberry;
}
.ppcp-r-vertical-text-control {
.components-base-control__field {
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 {
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 {
@include font(13, 24, 600);
color: var(--color-text-main);
margin: 0 0 4px 0;
margin: 0 0 12px 0;
display: block;
}
@ -68,6 +68,16 @@ $width_gap: 24px;
@include font(13, 20, 400);
color: var(--color-text-teriary);
margin: 0;
p {
margin: 0 0 12px 0;
}
button {
padding: 0;
margin: 0;
}
}
+ .ppcp-r-settings-card {

View file

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

View file

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

View file

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

View file

@ -1,58 +1,40 @@
import { Button } from '@wordpress/components';
import { Header, Title, Action, Description } from '../Elements';
import SettingsBlock from '../SettingsBlock';
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 printNotes = () => {
const notes = props.actionProps?.notes;
if ( ! notes || ( Array.isArray( notes ) && notes.length === 0 ) ) {
return null;
const { actionProps } = props;
const { isSandbox } = CommonHooks.useMerchant();
/**
* 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 (
<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 url;
};
return (
@ -60,38 +42,52 @@ const FeatureSettingsBlock = ( { title, description, ...props } ) => {
<Header>
<Title>
{ title }
{ props.actionProps?.enabled && (
<TitleBadge { ...props.actionProps?.badge } />
{ actionProps?.enabled && (
<TitleBadge { ...actionProps?.badge } />
) }
</Title>
<Description className="ppcp-r-settings-block__feature__description">
{ renderDescription() }
{ printNotes() }
<span
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>
</Header>
<Action>
<div className="ppcp--action-buttons">
{ props.actionProps?.buttons.map(
( {
{ actionProps?.buttons.map( ( buttonData ) => {
const {
class: className,
type,
text,
url,
urls,
onClick,
} ) => (
<FeatureButton
} = buttonData;
const buttonUrl = getButtonUrl( buttonData );
return (
<Button
key={ text }
className={ className }
variant={ type }
text={ text }
isBusy={ props.actionProps.isBusy }
url={ url }
urls={ urls }
onClick={ onClick }
/>
)
) }
isBusy={ actionProps.isBusy }
href={ buttonUrl }
target={ buttonUrl ? '_blank' : undefined }
onClick={ ! buttonUrl ? onClick : undefined }
>
{ text }
</Button>
);
} ) }
</div>
</Action>
</SettingsBlock>

View file

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

View file

@ -2,6 +2,18 @@ import classNames from 'classnames';
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 = ( {
id,
className,
@ -16,14 +28,20 @@ const SettingsCard = ( {
id,
};
const titleId = id ? `${ id }-title` : undefined;
const descriptionId = id ? `${ id }-description` : undefined;
return (
<div { ...cardProps }>
<div { ...cardProps } role="region" aria-labelledby={ titleId }>
<div className="ppcp-r-settings-card__header">
<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 }
</span>
<div className="ppcp-r-settings-card__description">
</h2>
<div
id={ descriptionId }
className="ppcp-r-settings-card__description"
>
{ description }
</div>
</div>

View file

@ -2,20 +2,24 @@ import { __ } from '@wordpress/i18n';
import { Spinner } from '@wordpress/components';
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', {
'ppcp--is-modal': asModal,
} );
if ( null === message ) {
message = __( 'Loading…', 'woocommerce-paypal-payments' );
}
return (
<div className={ className }>
{ message && (
<span className="ppcp--spinner-message">{ message }</span>
) }
<div className={ className } role="status" aria-label={ ariaLabel }>
<Spinner />
</div>
);

View file

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

View file

@ -10,6 +10,7 @@ import PaymentFlow from '../Components/PaymentFlow';
const StepPaymentMethods = () => {
const { optionalMethods, setOptionalMethods } =
OnboardingHooks.useOptionalPaymentMethods();
const { ownBrandOnly } = CommonHooks.useWooSettings();
const { isCasualSeller } = OnboardingHooks.useBusiness();
const optionalMethodTitle = useMemo( () => {
@ -31,7 +32,10 @@ const StepPaymentMethods = () => {
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',
'woocommerce-paypal-payments'
),
@ -41,7 +45,9 @@ const StepPaymentMethods = () => {
return (
<div className="ppcp-r-page-optional-payment-methods">
<OnboardingHeader title={ <PaymentStepTitle /> } />
<OnboardingHeader
title={ <PaymentStepTitle isBrandedOnly={ ownBrandOnly } /> }
/>
<div className="ppcp-r-inner-container">
<OptionSelector
multiSelect={ false }
@ -58,7 +64,13 @@ const 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' );
};

View file

@ -22,13 +22,14 @@ const StepWelcome = ( { setStep, currentStep } ) => {
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, and more.',
'Your all-in-one integration for PayPal checkout solutions that enable buyers to pay via PayPal, Pay Later, and more.',
'woocommerce-paypal-payments'
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,9 +34,10 @@ export const flags = ( state ) => {
* that should be returned.
*
* @param {{}} state
* @param {boolean} ownBrandOnly
* @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
* PartnerReferrals API. To avoid confusion with the "products" property from the
@ -58,8 +59,12 @@ export const determineProductsAndCaps = ( state ) => {
const { isCasualSeller, areOptionalPaymentMethodsEnabled, products } =
persistentData( state );
const { canUseVaulting, canUseCardPayments } = flags( state );
const isBrandedCasualSeller = isCasualSeller && ownBrandOnly;
const cardPaymentsEligibleAndSelected =
canUseCardPayments && areOptionalPaymentMethodsEnabled;
canUseCardPayments &&
areOptionalPaymentMethodsEnabled &&
! isBrandedCasualSeller;
if ( ! cardPaymentsEligibleAndSelected ) {
/**

View file

@ -31,7 +31,9 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
const { onboardingUrl } = isSandbox
? CommonHooks.useSandbox()
: CommonHooks.useProduction();
const { products, options } = OnboardingHooks.useDetermineProducts();
const { ownBrandOnly } = CommonHooks.useWooSettings();
const { products, options } =
OnboardingHooks.useDetermineProducts( ownBrandOnly );
const { startActivity } = CommonHooks.useBusyState();
const { authenticateWithOAuth } = CommonHooks.useAuthentication();
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\FeaturesEligibilityService;
use WooCommerce\PayPalCommerce\Settings\Service\GatewayRedirectService;
use WooCommerce\PayPalCommerce\Settings\Service\LoadingScreenService;
use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager;
use WooCommerce\PayPalCommerce\Settings\Service\TodosEligibilityService;
use WooCommerce\PayPalCommerce\Settings\Service\TodosSortingAndFilteringService;
@ -452,15 +453,26 @@ return 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(
'woocommerce_paypal_payments_rest_common_merchant_features',
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(
'apple_pay' => $features['apple_pay']['enabled'] ?? false,
'google_pay' => $features['google_pay']['enabled'] ?? false,
'acdc' => $features['advanced_credit_and_debit_cards']['enabled'] ?? false,
'apple_pay' => ( $features['apple_pay']['enabled'] ?? false ) && ! $general_settings->own_brand_only(),
'google_pay' => ( $features['google_pay']['enabled'] ?? false ) && ! $general_settings->own_brand_only(),
'acdc' => ( $features['advanced_credit_and_debit_cards']['enabled'] ?? false ) && ! $general_settings->own_brand_only(),
'save_paypal' => $features['save_paypal_and_venmo']['enabled'] ?? false,
'apm' => $features['alternative_payment_methods']['enabled'] ?? false,
'paylater' => $features['pay_later_messaging']['enabled'] ?? false,
@ -474,6 +486,8 @@ return array(
$button_locations = $container->get( 'settings.service.button_locations' );
$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' );
/**
@ -514,7 +528,7 @@ return array(
! $button_locations['cart_enabled'], // Add PayPal buttons to cart.
! $button_locations['block_checkout_enabled'], // Add PayPal buttons to block checkout.
! $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.
$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.
@ -591,6 +605,9 @@ return array(
'settings.service.gateway-redirect' => static function (): 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.
*

View file

@ -40,6 +40,13 @@ class GeneralSettings extends AbstractDataModel {
*/
protected array $woo_settings = array();
/**
* Contexts in which the installation path can be reset.
*/
private const ALLOWED_RESET_REASONS = array(
'plugin_uninstall',
);
/**
* Constructor.
*
@ -82,7 +89,7 @@ class GeneralSettings extends AbstractDataModel {
'seller_type' => 'unknown',
// 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 {
// 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;
}
@ -288,7 +295,7 @@ class GeneralSettings extends AbstractDataModel {
return;
}
$this->data['installation_path'] = $installation_path;
$this->data['wc_installation_path'] = $installation_path;
}
/**
@ -297,7 +304,23 @@ class GeneralSettings extends AbstractDataModel {
* @return 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' ),
'classic_checkout' => new LocationStylingDTO( 'classic_checkout' ),
'express_checkout' => new LocationStylingDTO( 'express_checkout' ),
'mini_cart' => new LocationStylingDTO( 'mini_cart' ),
'mini_cart' => new LocationStylingDTO( 'mini_cart', false ),
'product' => new LocationStylingDTO( 'product' ),
);
}

View file

@ -67,9 +67,18 @@ class ConnectionListener {
/**
* 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
*/
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.
@ -92,9 +101,6 @@ class ConnectionListener {
$this->authentication_manager = $authentication_manager;
$this->redirector = $redirector;
$this->logger = $logger ?: new NullLogger();
// Initialize as "guest", the real ID is provided via process().
$this->user_id = 0;
}
/**
@ -107,17 +113,42 @@ class ConnectionListener {
*/
public function process( int $user_id, array $request ) : void {
$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;
}
$token = $this->get_token_from_request( $request );
if ( ! $this->url_manager->validate_token_and_delete( $token, $this->user_id ) ) {
return;
}
$data = $this->extract_data( $request );
$data = $this->extract_data();
if ( ! $data ) {
return;
}
@ -126,22 +157,19 @@ class ConnectionListener {
try {
$this->authentication_manager->finish_oauth_authentication( $data );
$this->mark_token_as_processed( $token );
} catch ( \Exception $e ) {
$this->logger->error( 'Failed to complete authentication: ' . $e->getMessage() );
}
$this->redirect_after_authentication();
}
/**
* Determine, if the request details contain connection data that should be
* extracted and stored.
*
* @param array $request Request details to verify.
*
* @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 ) {
return false;
}
@ -157,7 +185,7 @@ class ConnectionListener {
);
foreach ( $required_params as $param ) {
if ( empty( $request[ $param ] ) ) {
if ( empty( $this->request_data[ $param ] ) ) {
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,
* 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...' );
$merchant_id = $this->get_merchant_id_from_request( $request );
$merchant_email = $this->get_merchant_email_from_request( $request );
$seller_type = $this->get_seller_type_from_request( $request );
$merchant_id = $this->get_merchant_id_from_request( $this->request_data );
$merchant_email = $this->get_merchant_email_from_request( $this->request_data );
$seller_type = $this->get_seller_type_from_request( $this->request_data );
if ( ! $merchant_id || ! $merchant_email ) {
return array();
@ -200,17 +252,16 @@ class ConnectionListener {
$redirect_url = $this->get_onboarding_redirect_url();
$this->redirector->redirect( $redirect_url );
exit;
}
/**
* Returns the sanitized connection token from the incoming request.
*
* @param array $request Full request details.
*
* @return string The sanitized token, or an empty string.
*/
private function get_token_from_request( array $request ) : string {
return $this->sanitize_string( $request['ppcpToken'] ?? '' );
private function get_token_from_request() : string {
return $this->sanitize_string( $this->request_data['ppcpToken'] ?? '' );
}
/**

View file

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

View file

@ -85,14 +85,9 @@ class GatewayRedirectService {
// Get current URL parameters.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash
// The sanitize_get_param method handles unslashing and sanitization internally.
$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
$page = isset( $_GET['page'] ) ? wc_clean( wp_unslash( $_GET['page'] ) ) : '';
$tab = isset( $_GET['tab'] ) ? wc_clean( wp_unslash( $_GET['tab'] ) ) : '';
$section = isset( $_GET['section'] ) ? wc_clean( wp_unslash( $_GET['section'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
// Check if we're on a WooCommerce settings page and checkout tab.
@ -113,17 +108,4 @@ class GatewayRedirectService {
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' );
$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(
$rest_url,
@ -69,6 +69,7 @@ class InternalRestService {
'cookies' => $auth_cookies,
)
);
$this->logger->debug( "Finished internal REST call [$rest_url]" );
if ( is_wp_error( $response ) ) {
// 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();
$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 ) {
$this->payment_methods->toggle_method_state( $method['id'], false );
}
@ -330,7 +333,7 @@ class SettingsDataManager {
'cart' => new LocationStylingDTO( 'cart', true, $methods_full ),
'classic_checkout' => new LocationStylingDTO( 'classic_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 ),
);

View file

@ -10,6 +10,7 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings;
use WC_Payment_Gateway;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PartnerAttribution;
use WooCommerce\PayPalCommerce\Applepay\Assets\AppleProductStatus;
@ -25,11 +26,13 @@ use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\P24Gateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\TrustlyGateway;
use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile;
use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel;
use WooCommerce\PayPalCommerce\Settings\Data\TodosModel;
use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener;
use WooCommerce\PayPalCommerce\Settings\Service\BrandedExperience\PathRepository;
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\ModuleClassNameIdTrait;
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(
'ppcp-switch-settings-ui',
'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 );
add_action(
@ -205,7 +213,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
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(
'ppcp-admin-settings',
'woocommerce-paypal-payments',
@ -281,10 +289,12 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_action(
'woocommerce_paypal_payments_gateway_admin_options_wrapper',
function () : void {
function () use ( $container ) : void {
global $hide_save_button;
$hide_save_button = true;
$this->initialize_branded_only( $container );
$this->render_header();
$this->render_content();
}
@ -329,6 +339,10 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_action(
'woocommerce_paypal_payments_merchant_disconnected',
static function () use ( $container ) : void {
$logger = $container->get( 'woocommerce.logger.woocommerce' );
assert( $logger instanceof LoggerInterface );
$logger->info( 'Merchant disconnected, reset onboarding' );
// Reset onboarding profile.
$onboarding_profile = $container->get( 'settings.data.onboarding' );
assert( $onboarding_profile instanceof OnboardingProfile );
@ -350,6 +364,10 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_action(
'woocommerce_paypal_payments_authenticated_merchant',
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' );
assert( $onboarding_profile instanceof OnboardingProfile );
@ -481,34 +499,42 @@ class SettingsModule implements ServiceModule, ExecutableModule {
$methods[] = $applepay_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;
},
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.
add_filter(
'woocommerce_available_payment_gateways',
@ -611,7 +637,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
// Enable Fastlane after onboarding if the store is compatible.
add_action(
'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 ) {
$compatibility_checker = $container->get( 'axo.helpers.compatibility-checker' );
assert( $compatibility_checker instanceof CompatibilityChecker );
@ -628,7 +654,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
// Toggle payment gateways after onboarding based on flags.
add_action(
'woocommerce_paypal_payments_sync_gateways',
static function() use ( $container ) {
static function () use ( $container ) {
$settings_data_manager = $container->get( 'settings.service.data-manager' );
assert( $settings_data_manager instanceof SettingsDataManager );
$settings_data_manager->sync_gateway_settings();
@ -640,6 +666,17 @@ class SettingsModule implements ServiceModule, ExecutableModule {
assert( $gateway_redirect_service instanceof GatewayRedirectService );
$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;
}
@ -667,6 +704,36 @@ class SettingsModule implements ServiceModule, ExecutableModule {
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).
*
@ -693,7 +760,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
* @param string $gateway_name The gateway name.
* @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_enabled = $gateway_settings['enabled'] ?? false;

View file

@ -49,7 +49,11 @@ class SubscriptionHelper {
return false;
}
$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;
}

View file

@ -8,7 +8,7 @@
* @package WooCommerce\WooCommerce\Logging\Logger
*/
declare(strict_types=1);
declare( strict_types = 1 );
namespace WooCommerce\WooCommerce\Logging\Logger;
@ -20,7 +20,6 @@ use Psr\Log\LoggerTrait;
*/
class WooCommerceLogger implements LoggerInterface {
use LoggerTrait;
/**
@ -35,7 +34,23 @@ class WooCommerceLogger implements LoggerInterface {
*
* @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.
@ -46,6 +61,15 @@ class WooCommerceLogger implements LoggerInterface {
public function __construct( \WC_Logger_Interface $wc_logger, string $source ) {
$this->wc_logger = $wc_logger;
$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";
}
/**
@ -59,6 +83,16 @@ class WooCommerceLogger implements LoggerInterface {
if ( ! isset( $context['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",
"version": "3.0.1",
"version": "3.0.4",
"description": "WooCommerce PayPal Payments",
"repository": "https://github.com/woocommerce/woocommerce-paypal-payments",
"license": "GPL-2.0",

View file

@ -2,9 +2,9 @@
Contributors: paypal, woocommerce, automattic, syde
Tags: woocommerce, paypal, payments, ecommerce, credit card
Requires at least: 6.5
Tested up to: 6.7
Tested up to: 6.8
Requires PHP: 7.4
Stable tag: 3.0.1
Stable tag: 3.0.4
License: GPLv2
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 ==
= 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 =
* Enhancement - Include Fastlane meta on homepage #3151
* Enhancement - Include Branded-only plugin configuration for certain installation paths

View file

@ -5,13 +5,15 @@
* @package WooCommerce\PayPalCommerce
*/
declare(strict_types=1);
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce;
use WooCommerce\PayPalCommerce\Uninstall\ClearDatabaseInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\NotFoundExceptionInterface;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings;
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
die( 'Direct access not allowed.' );
@ -20,13 +22,13 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
$root_dir = __DIR__;
$main_plugin_file = "{$root_dir}/woocommerce-paypal-payments.php";
if ( !file_exists( $main_plugin_file ) ) {
if ( ! file_exists( $main_plugin_file ) ) {
return;
}
require $main_plugin_file;
( static function (string $root_dir): void {
( static function ( string $root_dir ) : void {
$autoload_filepath = "{$root_dir}/vendor/autoload.php";
if ( file_exists( $autoload_filepath ) && ! class_exists( '\WooCommerce\PayPalCommerce\PluginModule' ) ) {
@ -39,9 +41,12 @@ require $main_plugin_file;
$app_container = $bootstrap( $root_dir );
assert( $app_container instanceof ContainerInterface );
clear_plugin_branding( $app_container );
$settings = $app_container->get( 'wcgateway.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' );
if ( ! $should_clear_db ) {
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 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.
* Version: 3.0.1
* Version: 3.0.4
* Author: PayPal
* Author URI: https://paypal.com/
* 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_SANDBOX_API_URL', 'https://api-m.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' );
! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' );