Merge pull request #2826 from woocommerce/PCP-3890-implement-sandbox-login-via-onboarding-wizard

Sandbox login via onboarding wizard & refactoring (3897)
This commit is contained in:
Philipp Stracker 2024-11-22 15:15:44 +01:00 committed by GitHub
commit 54f3812f7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2134 additions and 937 deletions

View file

@ -1,71 +1,116 @@
<?php
if (!defined('PAYPAL_INTEGRATION_DATE')) {
define('PAYPAL_INTEGRATION_DATE', '2023-06-02');
/**
* Stubs to help psalm correctly annotate problems in the plugin.
*
* @package WooCommerce
*/
if ( ! defined( 'PAYPAL_INTEGRATION_DATE' ) ) {
define( 'PAYPAL_INTEGRATION_DATE', '2023-06-02' );
}
if (!defined('PAYPAL_URL')) {
if ( ! defined( 'PAYPAL_URL' ) ) {
define( 'PAYPAL_URL', 'https://www.paypal.com' );
}
if (!defined('PAYPAL_SANDBOX_URL')) {
if ( ! defined( 'PAYPAL_SANDBOX_URL' ) ) {
define( 'PAYPAL_SANDBOX_URL', 'https://www.sandbox.paypal.com' );
}
if (!defined('EP_PAGES')) {
define('EP_PAGES', 4096);
if ( ! defined( 'EP_PAGES' ) ) {
define( 'EP_PAGES', 4096 );
}
if (!defined('MONTH_IN_SECONDS')) {
define('MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS);
if ( ! defined( 'MONTH_IN_SECONDS' ) ) {
define( 'MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS );
}
if (!defined('HOUR_IN_SECONDS')) {
define('HOUR_IN_SECONDS', 60 * MINUTE_IN_SECONDS);
if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
define( 'HOUR_IN_SECONDS', 60 * MINUTE_IN_SECONDS );
}
if (!defined('MINUTE_IN_SECONDS')) {
if ( ! defined( 'MINUTE_IN_SECONDS' ) ) {
define( 'MINUTE_IN_SECONDS', 60 );
}
if (!defined('ABSPATH')) {
define('ABSPATH', '');
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', '' );
}
if (!defined('PPCP_PAYPAL_BN_CODE')) {
define('PPCP_PAYPAL_BN_CODE', 'Woo_PPCP');
if ( ! defined( 'PAYPAL_API_URL' ) ) {
define( 'PAYPAL_API_URL', 'https://api-m.paypal.com' );
}
if ( ! defined( 'PAYPAL_SANDBOX_API_URL' ) ) {
define( 'PAYPAL_SANDBOX_API_URL', 'https://api-m.sandbox.paypal.com' );
}
if ( ! defined( 'PPCP_PAYPAL_BN_CODE' ) ) {
define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' );
}
if ( ! defined( 'CONNECT_WOO_CLIENT_ID' ) ) {
define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' );
}
if ( ! defined( 'CONNECT_WOO_SANDBOX_CLIENT_ID' ) ) {
define( 'CONNECT_WOO_SANDBOX_CLIENT_ID', 'AYmOHbt1VHg-OZ_oihPdzKEVbU3qg0qXonBcAztuzniQRaKE0w1Hr762cSFwd4n8wxOl-TCWohEa0XM_' );
}
if ( ! defined( 'CONNECT_WOO_MERCHANT_ID' ) ) {
define( 'CONNECT_WOO_MERCHANT_ID', 'K8SKZ36LQBWXJ' );
}
if ( ! defined( 'CONNECT_WOO_SANDBOX_MERCHANT_ID' ) ) {
define( 'CONNECT_WOO_SANDBOX_MERCHANT_ID', 'MPMFHQTVMBZ6G' );
}
if ( ! defined( 'CONNECT_WOO_URL' ) ) {
define( 'CONNECT_WOO_URL', 'https://api.woocommerce.com/integrations/ppc' );
}
if ( ! defined( 'CONNECT_WOO_SANDBOX_URL' ) ) {
define( 'CONNECT_WOO_SANDBOX_URL', 'https://api.woocommerce.com/integrations/ppcsandbox' );
}
/**
* Cancel the next occurrence of a scheduled action.
*
* While only the next instance of a recurring or cron action is unscheduled by this method, that will also prevent
* all future instances of that recurring or cron action from being run. Recurring and cron actions are scheduled in
* a sequence instead of all being scheduled at once. Each successive occurrence of a recurring action is scheduled
* only after the former action is run. If the next instance is never run, because it's unscheduled by this function,
* then the following instance will never be scheduled (or exist), which is effectively the same as being unscheduled
* by this method also.
* While only the next instance of a recurring or cron action is unscheduled by this method, that
* will also prevent all future instances of that recurring or cron action from being run.
* Recurring and cron actions are scheduled in a sequence instead of all being scheduled at once.
* Each successive occurrence of a recurring action is scheduled only after the former action is
* run. If the next instance is never run, because it's unscheduled by this function, then the
* following instance will never be scheduled (or exist), which is effectively the same as being
* unscheduled by this method also.
*
* @param string $hook The hook that the job will trigger.
* @param array $args Args that would have been passed to the job.
* @param string $hook The hook that the job will trigger.
* @param array $args Args that would have been passed to the job.
* @param string $group The group the job is assigned to.
*
* @return string|null The scheduled action ID if a scheduled action was found, or null if no matching action found.
* @return string|null The scheduled action ID if a scheduled action was found, or null if no
* matching action found.
*/
function as_unschedule_action($hook, $args = array(), $group = '') {}
function as_unschedule_action( $hook, $args = array(), $group = '' ) {
return null;
}
/**
* Schedule an action to run one time
*
* @param int $timestamp When the job will run.
* @param string $hook The hook to trigger.
* @param array $args Arguments to pass when the hook triggers.
* @param string $group The group to assign this job to.
* @param bool $unique Whether the action should be unique.
* @param string $hook The hook to trigger.
* @param array $args Arguments to pass when the hook triggers.
* @param string $group The group to assign this job to.
* @param bool $unique Whether the action should be unique.
*
* @return int The action ID.
*/
function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false ) {}
function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false ) {
return 0;
}
/**
* HTML API: WP_HTML_Tag_Processor class
*/
// phpcs:disable
class WP_HTML_Tag_Processor {
public function __construct( $html ) {}
public function next_tag( $query = null ) {}
public function set_attribute( $name, $value ) {}
public function get_updated_html() {}
public function __construct( $html ) {
}
public function next_tag( $query = null ) : bool {
return false;
}
public function set_attribute( $name, $value ) : bool {
return false;
}
public function get_updated_html() : string {
return '';
}
}

View file

@ -15,7 +15,6 @@ use WooCommerce\PayPalCommerce\ApiClient\Authentication\UserIdToken;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Orders;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentMethodTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\CardAuthenticationResult;
use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter;
use WooCommerce\PayPalCommerce\ApiClient\Helper\FailureRegistry;
@ -79,6 +78,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer;
return array(
'api.host' => function( ContainerInterface $container ) : string {
@ -179,6 +179,22 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'api.endpoint.partner-referrals-sandbox' => static function ( ContainerInterface $container ) : PartnerReferrals {
return new PartnerReferrals(
CONNECT_WOO_SANDBOX_URL,
new ConnectBearer(),
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'api.endpoint.partner-referrals-production' => static function ( ContainerInterface $container ) : PartnerReferrals {
return new PartnerReferrals(
CONNECT_WOO_URL,
new ConnectBearer(),
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'api.endpoint.identity-token' => static function ( ContainerInterface $container ) : IdentityToken {
$logger = $container->get( 'woocommerce.logger.woocommerce' );
$settings = $container->get( 'wcgateway.settings' );
@ -845,4 +861,22 @@ return array(
$container->get( 'api.client-credentials-cache' )
);
},
'api.paypal-host-production' => static function( ContainerInterface $container ) : string {
return PAYPAL_API_URL;
},
'api.paypal-host-sandbox' => static function( ContainerInterface $container ) : string {
return PAYPAL_SANDBOX_API_URL;
},
'api.paypal-website-url-production' => static function( ContainerInterface $container ) : string {
return PAYPAL_URL;
},
'api.paypal-website-url-sandbox' => static function( ContainerInterface $container ) : string {
return PAYPAL_SANDBOX_URL;
},
'api.partner_merchant_id-production' => static function( ContainerInterface $container ) : string {
return CONNECT_WOO_MERCHANT_ID;
},
'api.partner_merchant_id-sandbox' => static function( ContainerInterface $container ) : string {
return CONNECT_WOO_SANDBOX_MERCHANT_ID;
},
);

View file

@ -5,7 +5,7 @@
* @package WooCommerce\PayPalCommerce\ApiClient\Authentication
*/
declare(strict_types=1);
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\ApiClient\Authentication;
@ -28,7 +28,7 @@ class PayPalBearer implements Bearer {
/**
* The settings.
*
* @var ContainerInterface
* @var ?ContainerInterface
*/
protected $settings;
@ -70,12 +70,12 @@ class PayPalBearer implements Bearer {
/**
* PayPalBearer constructor.
*
* @param Cache $cache The cache.
* @param string $host The host.
* @param string $key The key.
* @param string $secret The secret.
* @param LoggerInterface $logger The logger.
* @param ContainerInterface $settings The settings.
* @param Cache $cache The cache.
* @param string $host The host.
* @param string $key The key.
* @param string $secret The secret.
* @param LoggerInterface $logger The logger.
* @param ?ContainerInterface $settings The settings.
*/
public function __construct(
Cache $cache,
@ -83,7 +83,7 @@ class PayPalBearer implements Bearer {
string $key,
string $secret,
LoggerInterface $logger,
ContainerInterface $settings
?ContainerInterface $settings
) {
$this->cache = $cache;
@ -97,27 +97,62 @@ class PayPalBearer implements Bearer {
/**
* Returns a bearer token.
*
* @return Token
* @throws RuntimeException When request fails.
* @return Token
*/
public function bearer(): Token {
public function bearer() : Token {
try {
$bearer = Token::from_json( (string) $this->cache->get( self::CACHE_KEY ) );
return ( $bearer->is_valid() ) ? $bearer : $this->newBearer();
} catch ( RuntimeException $error ) {
return $this->newBearer();
}
}
/**
* Retrieves the client key for authentication.
*
* @return string The client ID from settings, or the key defined via constructor.
*/
private function get_key() : string {
if (
$this->settings
&& $this->settings->has( 'client_id' )
&& $this->settings->get( 'client_id' )
) {
return $this->settings->get( 'client_id' );
}
return $this->key;
}
/**
* Retrieves the client secret for authentication.
*
* @return string The client secret from settings, or the value defined via constructor.
*/
private function get_secret() : string {
if (
$this->settings
&& $this->settings->has( 'client_secret' )
&& $this->settings->get( 'client_secret' )
) {
return $this->settings->get( 'client_secret' );
}
return $this->secret;
}
/**
* Creates a new bearer token.
*
* @return Token
* @throws RuntimeException When request fails.
* @return Token
*/
private function newBearer(): Token {
$key = $this->settings->has( 'client_id' ) && $this->settings->get( 'client_id' ) ? $this->settings->get( 'client_id' ) : $this->key;
$secret = $this->settings->has( 'client_secret' ) && $this->settings->get( 'client_secret' ) ? $this->settings->get( 'client_secret' ) : $this->secret;
private function newBearer() : Token {
$key = $this->get_key();
$secret = $this->get_secret();
$url = trailingslashit( $this->host ) . 'v1/oauth2/token?grant_type=client_credentials';
$args = array(
@ -127,10 +162,7 @@ class PayPalBearer implements Bearer {
'Authorization' => 'Basic ' . base64_encode( $key . ':' . $secret ),
),
);
$response = $this->request(
$url,
$args
);
$response = $this->request( $url, $args );
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
$error = new RuntimeException(
@ -148,6 +180,7 @@ class PayPalBearer implements Bearer {
$token = Token::from_json( $response['body'] );
$this->cache->set( self::CACHE_KEY, $token->as_json() );
return $token;
}
}

View file

@ -85,8 +85,7 @@ class PartnerReferrals {
$error = new RuntimeException(
__( 'Could not create referral.', 'woocommerce-paypal-payments' )
);
$this->logger->log(
'warning',
$this->logger->warning(
$error->getMessage(),
array(
'args' => $args,
@ -95,6 +94,7 @@ class PartnerReferrals {
);
throw $error;
}
$json = json_decode( $response['body'] );
$status_code = (int) wp_remote_retrieve_response_code( $response );
if ( 201 !== $status_code ) {
@ -102,8 +102,7 @@ class PartnerReferrals {
$json,
$status_code
);
$this->logger->log(
'warning',
$this->logger->warning(
$error->getMessage(),
array(
'args' => $args,
@ -122,8 +121,7 @@ class PartnerReferrals {
$error = new RuntimeException(
__( 'Action URL not found.', 'woocommerce-paypal-payments' )
);
$this->logger->log(
'warning',
$this->logger->warning(
$error->getMessage(),
array(
'args' => $args,

View file

@ -15,7 +15,6 @@ use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\LoginSeller;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\Onboarding\Assets\OnboardingAssets;
use WooCommerce\PayPalCommerce\Onboarding\Endpoint\LoginSellerEndpoint;
@ -25,7 +24,7 @@ use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingRenderer;
use WooCommerce\PayPalCommerce\Onboarding\OnboardingRESTController;
return array(
'api.sandbox-host' => static function ( ContainerInterface $container ): string {
'api.sandbox-host' => static function ( ContainerInterface $container ): string {
$state = $container->get( 'onboarding.state' );
@ -39,7 +38,7 @@ return array(
}
return CONNECT_WOO_SANDBOX_URL;
},
'api.production-host' => static function ( ContainerInterface $container ): string {
'api.production-host' => static function ( ContainerInterface $container ): string {
$state = $container->get( 'onboarding.state' );
@ -54,7 +53,7 @@ return array(
}
return CONNECT_WOO_URL;
},
'api.host' => static function ( ContainerInterface $container ): string {
'api.host' => static function ( ContainerInterface $container ): string {
$environment = $container->get( 'onboarding.environment' );
/**
@ -66,25 +65,7 @@ return array(
? (string) $container->get( 'api.sandbox-host' ) : (string) $container->get( 'api.production-host' );
},
'api.paypal-host-production' => static function( ContainerInterface $container ) : string {
return PAYPAL_API_URL;
},
'api.paypal-host-sandbox' => static function( ContainerInterface $container ) : string {
return PAYPAL_SANDBOX_API_URL;
},
'api.paypal-website-url-production' => static function( ContainerInterface $container ) : string {
return PAYPAL_URL;
},
'api.paypal-website-url-sandbox' => static function( ContainerInterface $container ) : string {
return PAYPAL_SANDBOX_URL;
},
'api.partner_merchant_id-production' => static function( ContainerInterface $container ) : string {
return CONNECT_WOO_MERCHANT_ID;
},
'api.partner_merchant_id-sandbox' => static function( ContainerInterface $container ) : string {
return CONNECT_WOO_SANDBOX_MERCHANT_ID;
},
'api.paypal-host' => function( ContainerInterface $container ) : string {
'api.paypal-host' => function( ContainerInterface $container ) : string {
$environment = $container->get( 'onboarding.environment' );
/**
* The current environment.
@ -97,7 +78,7 @@ return array(
return $container->get( 'api.paypal-host-production' );
},
'api.paypal-website-url' => function( ContainerInterface $container ) : string {
'api.paypal-website-url' => function( ContainerInterface $container ) : string {
$environment = $container->get( 'onboarding.environment' );
assert( $environment instanceof Environment );
if ( $environment->current_environment_is( Environment::SANDBOX ) ) {
@ -107,7 +88,7 @@ return array(
},
'api.bearer' => static function ( ContainerInterface $container ): Bearer {
'api.bearer' => static function ( ContainerInterface $container ): Bearer {
$state = $container->get( 'onboarding.state' );
@ -134,16 +115,16 @@ return array(
$settings
);
},
'onboarding.state' => function( ContainerInterface $container ) : State {
'onboarding.state' => function( ContainerInterface $container ) : State {
$settings = $container->get( 'wcgateway.settings' );
return new State( $settings );
},
'onboarding.environment' => function( ContainerInterface $container ) : Environment {
'onboarding.environment' => function( ContainerInterface $container ) : Environment {
$settings = $container->get( 'wcgateway.settings' );
return new Environment( $settings );
},
'onboarding.assets' => function( ContainerInterface $container ) : OnboardingAssets {
'onboarding.assets' => function( ContainerInterface $container ) : OnboardingAssets {
$state = $container->get( 'onboarding.state' );
$login_seller_endpoint = $container->get( 'onboarding.endpoint.login-seller' );
return new OnboardingAssets(
@ -156,14 +137,14 @@ return array(
);
},
'onboarding.url' => static function ( ContainerInterface $container ): string {
'onboarding.url' => static function ( ContainerInterface $container ): string {
return plugins_url(
'/modules/ppcp-onboarding/',
dirname( realpath( __FILE__ ), 3 ) . '/woocommerce-paypal-payments.php'
);
},
'api.endpoint.login-seller-production' => static function ( ContainerInterface $container ) : LoginSeller {
'api.endpoint.login-seller-production' => static function ( ContainerInterface $container ) : LoginSeller {
$logger = $container->get( 'woocommerce.logger.woocommerce' );
return new LoginSeller(
@ -173,7 +154,7 @@ return array(
);
},
'api.endpoint.login-seller-sandbox' => static function ( ContainerInterface $container ) : LoginSeller {
'api.endpoint.login-seller-sandbox' => static function ( ContainerInterface $container ) : LoginSeller {
$logger = $container->get( 'woocommerce.logger.woocommerce' );
return new LoginSeller(
@ -183,7 +164,7 @@ return array(
);
},
'onboarding.endpoint.login-seller' => static function ( ContainerInterface $container ) : LoginSellerEndpoint {
'onboarding.endpoint.login-seller' => static function ( ContainerInterface $container ) : LoginSellerEndpoint {
$request_data = $container->get( 'button.request-data' );
$login_seller_production = $container->get( 'api.endpoint.login-seller-production' );
@ -203,7 +184,7 @@ return array(
new Cache( 'ppcp-client-credentials-cache' )
);
},
'onboarding.endpoint.pui' => static function( ContainerInterface $container ) : UpdateSignupLinksEndpoint {
'onboarding.endpoint.pui' => static function( ContainerInterface $container ) : UpdateSignupLinksEndpoint {
return new UpdateSignupLinksEndpoint(
$container->get( 'wcgateway.settings' ),
$container->get( 'button.request-data' ),
@ -213,26 +194,10 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'api.endpoint.partner-referrals-sandbox' => static function ( ContainerInterface $container ) : PartnerReferrals {
return new PartnerReferrals(
CONNECT_WOO_SANDBOX_URL,
new ConnectBearer(),
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'api.endpoint.partner-referrals-production' => static function ( ContainerInterface $container ) : PartnerReferrals {
return new PartnerReferrals(
CONNECT_WOO_URL,
new ConnectBearer(),
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'onboarding.signup-link-cache' => static function( ContainerInterface $container ): Cache {
'onboarding.signup-link-cache' => static function( ContainerInterface $container ): Cache {
return new Cache( 'ppcp-paypal-signup-link' );
},
'onboarding.signup-link-ids' => static function ( ContainerInterface $container ): array {
'onboarding.signup-link-ids' => static function ( ContainerInterface $container ): array {
return array(
'production-ppcp',
'production-express_checkout',
@ -240,12 +205,12 @@ return array(
'sandbox-express_checkout',
);
},
'onboarding.render-send-only-notice' => static function( ContainerInterface $container ) {
'onboarding.render-send-only-notice' => static function( ContainerInterface $container ) {
return new OnboardingSendOnlyNoticeRenderer(
$container->get( 'wcgateway.send-only-message' )
);
},
'onboarding.render' => static function ( ContainerInterface $container ) : OnboardingRenderer {
'onboarding.render' => static function ( ContainerInterface $container ) : OnboardingRenderer {
$partner_referrals = $container->get( 'api.endpoint.partner-referrals-production' );
$partner_referrals_sandbox = $container->get( 'api.endpoint.partner-referrals-sandbox' );
$partner_referrals_data = $container->get( 'api.repository.partner-referrals-data' );
@ -261,14 +226,14 @@ return array(
$logger
);
},
'onboarding.render-options' => static function ( ContainerInterface $container ) : OnboardingOptionsRenderer {
'onboarding.render-options' => static function ( ContainerInterface $container ) : OnboardingOptionsRenderer {
return new OnboardingOptionsRenderer(
$container->get( 'onboarding.url' ),
$container->get( 'api.shop.country' ),
$container->get( 'wcgateway.settings' )
);
},
'onboarding.rest' => static function( $container ) : OnboardingRESTController {
'onboarding.rest' => static function( $container ) : OnboardingRESTController {
return new OnboardingRESTController( $container );
},
);

View file

@ -2,13 +2,22 @@
display: flex;
align-items: center;
&__space,
&__line {
height: 1px;
background-color: $color-gray-600;
margin: 0;
display: block;
width: 100%;
}
&__line {
background-color: $color-gray-600;
height: 1px;
}
&__space {
margin-bottom: 48px;
}
&__text {
color: $color-gray;
@include font(12, 24, 500, 0.8px);

View file

@ -1,7 +1,8 @@
body:has(.ppcp-r-container--onboarding) {
background-color: #fff !important;
.notice, .nav-tab-wrapper.woo-nav-tab-wrapper, .woocommerce-layout, .wrap.woocommerce form > h2, #screen-meta-links {
.notice, .nav-tab-wrapper.woo-nav-tab-wrapper, .woocommerce-layout__header, .wrap.woocommerce form > h2, #screen-meta-links {
display: none !important;
visibility: hidden;
}
}

View file

@ -20,14 +20,6 @@
margin: 0 0 16px 0;
}
.ppcp-r-page-welcome-mode-separator {
margin: 0 0 48px 0;
.ppcp-r-separator__line {
background-color: $color-gray-300;
}
}
.components-base-control__field {
margin: 0 0 24px 0;
}

View file

@ -1,3 +1,4 @@
import { useEffect } from '@wordpress/element';
import { Icon } from '@wordpress/components';
import { chevronDown, chevronUp } from '@wordpress/icons';
@ -5,11 +6,33 @@ import { useState } from 'react';
const Accordion = ( {
title,
initiallyOpen = false,
initiallyOpen = null,
className = '',
id = '',
children,
} ) => {
const [ isOpen, setIsOpen ] = useState( initiallyOpen );
const determineInitialState = () => {
if ( id && initiallyOpen === null ) {
return window.location.hash === `#${ id }`;
}
return !! initiallyOpen;
};
const [ isOpen, setIsOpen ] = useState( determineInitialState );
useEffect( () => {
const handleHashChange = () => {
if ( id && window.location.hash === `#${ id }` ) {
setIsOpen( true );
}
};
window.addEventListener( 'hashchange', handleHashChange );
return () => {
window.removeEventListener( 'hashchange', handleHashChange );
};
}, [ id ] );
const toggleOpen = ( ev ) => {
setIsOpen( ! isOpen );
@ -26,7 +49,7 @@ const Accordion = ( {
}
return (
<div className={ wrapperClasses.join( ' ' ) }>
<div className={ wrapperClasses.join( ' ' ) } id={ id }>
<button
onClick={ toggleOpen }
className="ppcp-r-accordion--title"

View file

@ -1,24 +1,32 @@
const Separator = ( props ) => {
let separatorClass = 'ppcp-r-separator';
const Separator = ( { className = '', text = '', withLine = true } ) => {
const separatorClass = [ 'ppcp-r-separator' ];
const innerClass = withLine
? 'ppcp-r-separator__line'
: 'ppcp-r-separator__space';
if ( props?.className ) {
separatorClass += ' ' + props.className;
if ( className ) {
separatorClass.push( className );
}
if ( props.text ) {
return (
<div className={ separatorClass }>
<span className="ppcp-r-separator__line ppcp-r-separator__line--before"></span>
const getClass = ( type ) => `${ innerClass } ${ innerClass }--${ type }`;
<span className="ppcp-r-separator__text">{ props.text }</span>
<span className="ppcp-r-separator__line ppcp-r-separator__line--after"></span>
</div>
);
}
const renderSeparator = () => {
if ( text ) {
return (
<>
<span className={ getClass( 'before' ) }></span>
<span className="ppcp-r-separator__text">{ text }</span>
<span className={ getClass( 'after' ) }></span>
</>
);
}
return <span className={ getClass( 'full' ) }></span>;
};
return (
<div className={ separatorClass }>
<span className="ppcp-r-separator__line ppcp-r-separator__line--before"></span>
<div className={ separatorClass.join( ' ' ) }>
{ renderSeparator() }
</div>
);
};

View file

@ -7,24 +7,25 @@ import { store as noticesStore } from '@wordpress/notices';
import SettingsToggleBlock from '../../../ReusableComponents/SettingsToggleBlock';
import Separator from '../../../ReusableComponents/Separator';
import DataStoreControl from '../../../ReusableComponents/DataStoreControl';
import { useManualConnect, useOnboardingStepWelcome } from '../../../../data';
import { CommonHooks } from '../../../../data';
import { openPopup } from '../../../../utils/window';
const AdvancedOptionsForm = ( { setCompleted } ) => {
const { isBusy } = CommonHooks.useBusyState();
const { isSandboxMode, setSandboxMode, connectViaSandbox } =
CommonHooks.useSandbox();
const {
isManualConnectionBusy,
isSandboxMode,
setSandboxMode,
isManualConnectionMode,
setManualConnectionMode,
clientId,
setClientId,
clientSecret,
setClientSecret,
} = useOnboardingStepWelcome();
connectViaIdAndSecret,
} = CommonHooks.useManualConnection();
const { createSuccessNotice, createErrorNotice } =
useDispatch( noticesStore );
const { connectManual } = useManualConnect();
const refClientId = useRef( null );
const refClientSecret = useRef( null );
@ -61,17 +62,9 @@ const AdvancedOptionsForm = ( { setCompleted } ) => {
return true;
};
const handleServerError = ( res ) => {
if ( res.message ) {
createErrorNotice( res.message );
} else {
createErrorNotice(
__(
'Could not connect to PayPal. Please make sure your Client ID and Secret Key are correct.',
'woocommerce-paypal-payments'
)
);
}
const handleServerError = ( res, genericMessage ) => {
console.error( 'Connection error', res );
createErrorNotice( res?.message ?? genericMessage );
};
const handleServerSuccess = () => {
@ -82,17 +75,50 @@ const AdvancedOptionsForm = ( { setCompleted } ) => {
setCompleted( true );
};
const handleConnect = async () => {
const handleSandboxConnect = async () => {
const res = await connectViaSandbox();
if ( ! res.success || ! res.data ) {
handleServerError(
res,
__(
'Could not generate a Sandbox login link.',
'woocommerce-paypal-payments'
)
);
return;
}
const connectionUrl = res.data;
const popup = openPopup( connectionUrl );
if ( ! popup ) {
createErrorNotice(
__(
'Popup blocked. Please allow popups for this site to connect to PayPal.',
'woocommerce-paypal-payments'
)
);
}
};
const handleManualConnect = async () => {
if ( ! handleFormValidation() ) {
return;
}
const res = await connectManual();
const res = await connectViaIdAndSecret();
if ( res.success ) {
handleServerSuccess();
} else {
handleServerError( res );
handleServerError(
res,
__(
'Could not connect to PayPal. Please make sure your Client ID and Secret Key are correct.',
'woocommerce-paypal-payments'
)
);
}
};
@ -118,21 +144,22 @@ const AdvancedOptionsForm = ( { setCompleted } ) => {
) }
isToggled={ !! isSandboxMode }
setToggled={ setSandboxMode }
isLoading={ isBusy }
>
<Button variant="secondary">
<Button onClick={ handleSandboxConnect } variant="secondary">
{ __( 'Connect Account', 'woocommerce-paypal-payments' ) }
</Button>
</SettingsToggleBlock>
<Separator className="ppcp-r-page-welcome-mode-separator" />
<Separator withLine={ false } />
<SettingsToggleBlock
label={ __(
'Manually Connect',
'woocommerce-paypal-payments'
) }
label={
__( 'Manually Connect', 'woocommerce-paypal-payments' ) +
( isBusy ? ' ...' : '' )
}
description={ advancedUsersDescription }
isToggled={ !! isManualConnectionMode }
setToggled={ setManualConnectionMode }
isLoading={ isManualConnectionBusy }
isLoading={ isBusy }
>
<DataStoreControl
control={ TextControl }
@ -169,7 +196,7 @@ const AdvancedOptionsForm = ( { setCompleted } ) => {
onChange={ setClientSecret }
type="password"
/>
<Button variant="secondary" onClick={ handleConnect }>
<Button variant="secondary" onClick={ handleManualConnect }>
{ __( 'Connect Account', 'woocommerce-paypal-payments' ) }
</Button>
</SettingsToggleBlock>

View file

@ -1,10 +1,8 @@
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import {
useOnboardingStepBusiness,
useOnboardingStepProducts,
} from '../../data';
import data from '../../utils/data';
import { OnboardingHooks } from '../../../../data';
import data from '../../../../utils/data';
const Navigation = ( { setStep, setCompleted, currentStep, stepperOrder } ) => {
const isLastStep = () => currentStep + 1 === stepperOrder.length;
@ -24,8 +22,8 @@ const Navigation = ( { setStep, setCompleted, currentStep, stepperOrder } ) => {
}
};
const { products, toggleProduct } = useOnboardingStepProducts();
const { isCasualSeller, setIsCasualSeller } = useOnboardingStepBusiness();
const { products } = OnboardingHooks.useProducts();
const { isCasualSeller } = OnboardingHooks.useBusiness();
let navigationTitle = '';
let disabled = false;

View file

@ -1,7 +1,7 @@
import Container from '../../ReusableComponents/Container';
import { useOnboardingStep } from '../../../data';
import { OnboardingHooks } from '../../../data';
import { getSteps } from './availableSteps';
import Navigation from '../../ReusableComponents/Navigation';
import Navigation from './Components/Navigation';
const getCurrentStep = ( requestedStep, steps ) => {
const isValidStep = ( step ) =>
@ -15,7 +15,7 @@ const getCurrentStep = ( requestedStep, steps ) => {
};
const Onboarding = () => {
const { step, setStep, setCompleted, flags } = useOnboardingStep();
const { step, setStep, setCompleted, flags } = OnboardingHooks.useSteps();
const steps = getSteps( flags );
const CurrentStepComponent = getCurrentStep( step, steps );

View file

@ -1,20 +1,14 @@
import { __ } from '@wordpress/i18n';
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper';
import SelectBox from '../../ReusableComponents/SelectBox';
import { __ } from '@wordpress/i18n';
import PaymentMethodIcons from '../../ReusableComponents/PaymentMethodIcons';
import { useOnboardingStepBusiness } from '../../../data';
import { BUSINESS_TYPES } from '../../../data/constants';
import { OnboardingHooks, BUSINESS_TYPES } from '../../../data';
const BUSINESS_RADIO_GROUP_NAME = 'business';
const StepBusiness = ( {
setStep,
currentStep,
stepperOrder,
setCompleted,
} ) => {
const { isCasualSeller, setIsCasualSeller } = useOnboardingStepBusiness();
const StepBusiness = ( {} ) => {
const { isCasualSeller, setIsCasualSeller } = OnboardingHooks.useBusiness();
const handleSellerTypeChange = ( value ) => {
setIsCasualSeller( BUSINESS_TYPES.CASUAL_SELLER === value );
@ -40,23 +34,22 @@ const StepBusiness = ( {
/>
<div className="ppcp-r-inner-container">
<SelectBoxWrapper>
<SelectBox
title={ __(
'Business',
'woocommerce-paypal-payments'
) }
description={ __(
'Recommended for individuals and organizations that primarily use PayPal to sell goods or services or receive donations, even if your business is not incorporated.',
'woocommerce-paypal-payments'
) }
name={ BUSINESS_RADIO_GROUP_NAME }
value={ BUSINESS_TYPES.BUSINESS }
changeCallback={ handleSellerTypeChange }
currentValue={ getCurrentValue() }
checked={ isCasualSeller === false }
type="radio"
>
</SelectBox>
<SelectBox
title={ __(
'Business',
'woocommerce-paypal-payments'
) }
description={ __(
'Recommended for individuals and organizations that primarily use PayPal to sell goods or services or receive donations, even if your business is not incorporated.',
'woocommerce-paypal-payments'
) }
name={ BUSINESS_RADIO_GROUP_NAME }
value={ BUSINESS_TYPES.BUSINESS }
changeCallback={ handleSellerTypeChange }
currentValue={ getCurrentValue() }
checked={ isCasualSeller === false }
type="radio"
></SelectBox>
<SelectBox
title={ __(
'Personal Account',
@ -72,8 +65,7 @@ const StepBusiness = ( {
currentValue={ getCurrentValue() }
checked={ isCasualSeller === true }
type="radio"
>
</SelectBox>
></SelectBox>
</SelectBoxWrapper>
</div>
</div>

View file

@ -1,13 +1,9 @@
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
import { __ } from '@wordpress/i18n';
import { Button, Icon } from '@wordpress/components';
const StepCompleteSetup = ( {
setStep,
currentStep,
stepperOrder,
setCompleted,
} ) => {
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
const StepCompleteSetup = ( { setCompleted } ) => {
const ButtonIcon = () => (
<Icon
icon={ () => (

View file

@ -1,19 +1,14 @@
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
import { __ } from '@wordpress/i18n';
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
import SelectBox from '../../ReusableComponents/SelectBox';
import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper';
import { useOnboardingStepProducts } from '../../../data';
import { PRODUCT_TYPES } from '../../../data/constants';
import { OnboardingHooks, PRODUCT_TYPES } from '../../../data';
const PRODUCTS_CHECKBOX_GROUP_NAME = 'products';
const StepProducts = ( {
setStep,
currentStep,
stepperOrder,
setCompleted,
} ) => {
const { products, toggleProduct } = useOnboardingStepProducts();
const StepProducts = () => {
const { products, setProducts } = OnboardingHooks.useProducts();
return (
<div className="ppcp-r-page-products">
@ -33,7 +28,7 @@ const StepProducts = ( {
) }
name={ PRODUCTS_CHECKBOX_GROUP_NAME }
value={ PRODUCT_TYPES.VIRTUAL }
changeCallback={ toggleProduct }
changeCallback={ setProducts }
currentValue={ products }
type="checkbox"
>
@ -75,7 +70,7 @@ const StepProducts = ( {
) }
name={ PRODUCTS_CHECKBOX_GROUP_NAME }
value={ PRODUCT_TYPES.PHYSICAL }
changeCallback={ toggleProduct }
changeCallback={ setProducts }
currentValue={ products }
type="checkbox"
>
@ -102,7 +97,7 @@ const StepProducts = ( {
) }
name={ PRODUCTS_CHECKBOX_GROUP_NAME }
value={ PRODUCT_TYPES.SUBSCRIPTIONS }
changeCallback={ toggleProduct }
changeCallback={ setProducts }
currentValue={ products }
type="checkbox"
>

View file

@ -1,13 +1,13 @@
import { __, sprintf } from '@wordpress/i18n';
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
import PaymentMethodIcons from '../../ReusableComponents/PaymentMethodIcons';
import Separator from '../../ReusableComponents/Separator';
import WelcomeDocs from '../../ReusableComponents/WelcomeDocs/WelcomeDocs';
import AccordionSection from '../../ReusableComponents/AccordionSection';
import AdvancedOptionsForm from './Components/AdvancedOptionsForm';
import AccordionSection from '../../ReusableComponents/AccordionSection';
const StepWelcome = ( { setStep, currentStep, setCompleted } ) => {
return (
@ -57,7 +57,7 @@ const StepWelcome = ( { setStep, currentStep, setCompleted } ) => {
'woocommerce-paypal-payments'
) }
className="onboarding-advanced-options"
initiallyOpen={ false }
id="advanced-options"
>
<AdvancedOptionsForm setCompleted={ setCompleted } />
</AccordionSection>

View file

@ -1,9 +1,9 @@
import { useOnboardingStep } from '../../data';
import { OnboardingHooks } from '../../data';
import Onboarding from './Onboarding/Onboarding';
import SettingsScreen from './SettingsScreen';
const Settings = () => {
const onboardingProgress = useOnboardingStep();
const onboardingProgress = OnboardingHooks.useSteps();
if ( ! onboardingProgress.isReady ) {
// TODO: Use better loading state indicator.

View file

@ -0,0 +1,19 @@
/**
* Action Types: Define unique identifiers for actions across all store modules.
*
* @file
*/
export default {
// Transient data.
SET_TRANSIENT: 'COMMON:SET_TRANSIENT',
// Persistent data.
SET_PERSISTENT: 'COMMON:SET_PERSISTENT',
HYDRATE: 'COMMON:HYDRATE',
// Controls - always start with "DO_".
DO_PERSIST_DATA: 'COMMON:DO_PERSIST_DATA',
DO_MANUAL_CONNECTION: 'COMMON:DO_MANUAL_CONNECTION',
DO_SANDBOX_LOGIN: 'COMMON:DO_SANDBOX_LOGIN',
};

View file

@ -0,0 +1,154 @@
/**
* Action Creators: Define functions to create action objects.
*
* These functions update state or trigger side effects (e.g., async operations).
* Actions are categorized as Transient, Persistent, or Side effect.
*
* @file
*/
import { select } from '@wordpress/data';
import ACTION_TYPES from './action-types';
import { STORE_NAME } from './constants';
/**
* @typedef {Object} Action An action object that is handled by a reducer or control.
* @property {string} type - The action type.
* @property {Object?} payload - Optional payload for the action.
*/
/**
* Persistent. Set the full onboarding details, usually during app initialization.
*
* @param {{data: {}, flags?: {}}} payload
* @return {Action} The action.
*/
export const hydrate = ( payload ) => ( {
type: ACTION_TYPES.HYDRATE,
payload,
} );
/**
* Transient. Marks the onboarding details as "ready", i.e., fully initialized.
*
* @param {boolean} isReady
* @return {Action} The action.
*/
export const setIsReady = ( isReady ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isReady },
} );
/**
* Transient. Changes the "saving" flag.
*
* @param {boolean} isSaving
* @return {Action} The action.
*/
export const setIsSaving = ( isSaving ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isSaving },
} );
/**
* Transient. Changes the "manual connection is busy" flag.
*
* @param {boolean} isBusy
* @return {Action} The action.
*/
export const setIsBusy = ( isBusy ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isBusy },
} );
/**
* Persistent. Sets the sandbox mode on or off.
*
* @param {boolean} useSandbox
* @return {Action} The action.
*/
export const setSandboxMode = ( useSandbox ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { useSandbox },
} );
/**
* Persistent. Toggles the "Manual Connection" mode on or off.
*
* @param {boolean} useManualConnection
* @return {Action} The action.
*/
export const setManualConnectionMode = ( useManualConnection ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { useManualConnection },
} );
/**
* Persistent. Changes the "client ID" value.
*
* @param {string} clientId
* @return {Action} The action.
*/
export const setClientId = ( clientId ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { clientId },
} );
/**
* Persistent. Changes the "client secret" value.
*
* @param {string} clientSecret
* @return {Action} The action.
*/
export const setClientSecret = ( clientSecret ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { clientSecret },
} );
/**
* Side effect. Saves the persistent details to the WP database.
*
* @return {Action} The action.
*/
export const persist = function* () {
const data = yield select( STORE_NAME ).persistentData();
yield { type: ACTION_TYPES.DO_PERSIST_DATA, data };
};
/**
* Side effect. Initiates the sandbox login ISU.
*
* @return {Action} The action.
*/
export const connectViaSandbox = function* () {
yield setIsBusy( true );
const result = yield { type: ACTION_TYPES.DO_SANDBOX_LOGIN };
yield setIsBusy( false );
return result;
};
/**
* Side effect. Initiates a manual connection attempt using the provided client ID and secret.
*
* @return {Action} The action.
*/
export const connectViaIdAndSecret = function* () {
const { clientId, clientSecret, useSandbox } =
yield select( STORE_NAME ).persistentData();
yield setIsBusy( true );
const result = yield {
type: ACTION_TYPES.DO_MANUAL_CONNECTION,
clientId,
clientSecret,
useSandbox,
};
yield setIsBusy( false );
return result;
};

View file

@ -0,0 +1,46 @@
/**
* Name of the module-store in the main Redux store.
*
* Helps to isolate data, used by reducer and selectors.
*
* @type {string}
*/
export const STORE_NAME = 'wc/paypal/common';
/**
* REST path to hydrate data of this module by loading data from the WP DB..
*
* Used by resolvers.
*
* @type {string}
*/
export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/common';
/**
* REST path to persist data of this module to the WP DB.
*
* Used by controls.
*
* @type {string}
*/
export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/common';
/**
* REST path to perform the manual connection check, using client ID and secret,
*
* Used by: Controls
* See: ConnectManualRestEndpoint.php
*
* @type {string}
*/
export const REST_MANUAL_CONNECTION_PATH = '/wc/v3/wc_paypal/connect_manual';
/**
* REST path to generate an ISU URL for the sandbox-login.
*
* Used by: Controls
* See: LoginLinkRestEndpoint.php
*
* @type {string}
*/
export const REST_SANDBOX_CONNECTION_PATH = '/wc/v3/wc_paypal/login_link';

View file

@ -0,0 +1,80 @@
/**
* Controls: Implement side effects, typically asynchronous operations.
*
* Controls use ACTION_TYPES keys as identifiers.
* They are triggered by corresponding actions and handle external interactions.
*
* @file
*/
import apiFetch from '@wordpress/api-fetch';
import {
REST_PERSIST_PATH,
REST_MANUAL_CONNECTION_PATH,
REST_SANDBOX_CONNECTION_PATH,
} from './constants';
import ACTION_TYPES from './action-types';
export const controls = {
async [ ACTION_TYPES.DO_PERSIST_DATA ]( { data } ) {
try {
return await apiFetch( {
path: REST_PERSIST_PATH,
method: 'POST',
data,
} );
} catch ( error ) {
console.error( 'Error saving data.', error );
}
},
async [ ACTION_TYPES.DO_SANDBOX_LOGIN ]() {
let result = null;
try {
result = await apiFetch( {
path: REST_SANDBOX_CONNECTION_PATH,
method: 'POST',
data: {
environment: 'sandbox',
products: [ 'EXPRESS_CHECKOUT' ],
},
} );
} catch ( e ) {
result = {
success: false,
error: e,
};
}
return result;
},
async [ ACTION_TYPES.DO_MANUAL_CONNECTION ]( {
clientId,
clientSecret,
useSandbox,
} ) {
let result = null;
try {
result = await apiFetch( {
path: REST_MANUAL_CONNECTION_PATH,
method: 'POST',
data: {
clientId,
clientSecret,
useSandbox,
},
} );
} catch ( e ) {
result = {
success: false,
error: e,
};
}
return result;
},
};

View file

@ -0,0 +1,111 @@
/**
* Hooks: Provide the main API for components to interact with the store.
*
* These encapsulate store interactions, offering a consistent interface.
* Hooks simplify data access and manipulation for components.
*
* @file
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { STORE_NAME } from './constants';
const useTransient = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).transientData()?.[ key ],
[ key ]
);
const usePersistent = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).persistentData()?.[ key ],
[ key ]
);
const useHooks = () => {
const {
persist,
setSandboxMode,
setManualConnectionMode,
setClientId,
setClientSecret,
connectViaSandbox,
connectViaIdAndSecret,
} = useDispatch( STORE_NAME );
// Transient accessors.
const isReady = useTransient( 'isReady' );
// Persistent accessors.
const clientId = usePersistent( 'clientId' );
const clientSecret = usePersistent( 'clientSecret' );
const isSandboxMode = usePersistent( 'useSandbox' );
const isManualConnectionMode = usePersistent( 'useManualConnection' );
const savePersistent = async ( setter, value ) => {
setter( value );
await persist();
};
return {
isReady,
isSandboxMode,
setSandboxMode: ( state ) => {
return savePersistent( setSandboxMode, state );
},
isManualConnectionMode,
setManualConnectionMode: ( state ) => {
return savePersistent( setManualConnectionMode, state );
},
clientId,
setClientId: ( value ) => {
return savePersistent( setClientId, value );
},
clientSecret,
setClientSecret: ( value ) => {
return savePersistent( setClientSecret, value );
},
connectViaSandbox,
connectViaIdAndSecret,
};
};
export const useBusyState = () => {
const { setIsBusy } = useDispatch( STORE_NAME );
const isBusy = useTransient( 'isBusy' );
return {
isBusy,
setIsBusy: useCallback( ( busy ) => setIsBusy( busy ), [ setIsBusy ] ),
};
};
export const useSandbox = () => {
const { isSandboxMode, setSandboxMode, connectViaSandbox } = useHooks();
return { isSandboxMode, setSandboxMode, connectViaSandbox };
};
export const useManualConnection = () => {
const {
isManualConnectionMode,
setManualConnectionMode,
clientId,
setClientId,
clientSecret,
setClientSecret,
connectViaIdAndSecret,
} = useHooks();
return {
isManualConnectionMode,
setManualConnectionMode,
clientId,
setClientId,
clientSecret,
setClientSecret,
connectViaIdAndSecret,
};
};

View file

@ -0,0 +1,24 @@
import { createReduxStore, register } from '@wordpress/data';
import { controls as wpControls } from '@wordpress/data-controls';
import { STORE_NAME } from './constants';
import reducer from './reducer';
import * as selectors from './selectors';
import * as actions from './actions';
import * as hooks from './hooks';
import { resolvers } from './resolvers';
import { controls } from './controls';
export const initStore = () => {
const store = createReduxStore( STORE_NAME, {
reducer,
controls: { ...wpControls, ...controls },
actions,
selectors,
resolvers,
} );
register( store );
};
export { hooks, selectors, STORE_NAME };

View file

@ -0,0 +1,45 @@
/**
* Reducer: Defines store structure and state updates for this module.
*
* Manages both transient (temporary) and persistent (saved) state.
* The initial state must define all properties, as dynamic additions are not supported.
*
* @file
*/
import { createReducer, createSetters } from '../utils';
import ACTION_TYPES from './action-types';
// Store structure.
const defaultTransient = {
isReady: false,
isBusy: false,
};
const defaultPersistent = {
useSandbox: false,
useManualConnection: false,
clientId: '',
clientSecret: '',
};
// Reducer logic.
const [ setTransient, setPersistent ] = createSetters(
defaultTransient,
defaultPersistent
);
const commonReducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, action ) =>
setTransient( state, action ),
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, action ) =>
setPersistent( state, action ),
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) =>
setPersistent( state, payload.data ),
} );
export default commonReducer;

View file

@ -0,0 +1,36 @@
/**
* Resolvers: Handle asynchronous data fetching for the store.
*
* These functions update store state with data from external sources.
* Each resolver corresponds to a specific selector (selector with same name must exist).
* Resolvers are called automatically when selectors request unavailable data.
*
* @file
*/
import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { apiFetch } from '@wordpress/data-controls';
import { STORE_NAME, REST_HYDRATE_PATH } from './constants';
export const resolvers = {
/**
* Retrieve settings from the site's REST API.
*/
*persistentData() {
try {
const result = yield apiFetch( { path: REST_HYDRATE_PATH } );
yield dispatch( STORE_NAME ).hydrate( result );
yield dispatch( STORE_NAME ).setIsReady( true );
} catch ( e ) {
yield dispatch( 'core/notices' ).createErrorNotice(
__(
'Error retrieving plugin details.',
'woocommerce-paypal-payments'
)
);
}
},
};

View file

@ -0,0 +1,21 @@
/**
* Selectors: Extract specific pieces of state from the store.
*
* These functions provide a consistent interface for accessing store data.
* They allow components to retrieve data without knowing the store structure.
*
* @file
*/
const EMPTY_OBJ = Object.freeze( {} );
const getState = ( state ) => state || EMPTY_OBJ;
export const persistentData = ( state ) => {
return getState( state ).data || EMPTY_OBJ;
};
export const transientData = ( state ) => {
const { data, ...transientState } = getState( state );
return transientState || EMPTY_OBJ;
};

View file

@ -1,6 +1,3 @@
export const NAMESPACE = '/wc/v3/wc_paypal';
export const STORE_NAME = 'wc/paypal';
export const BUSINESS_TYPES = {
CASUAL_SELLER: 'casual_seller',
BUSINESS: 'business',

View file

@ -0,0 +1,47 @@
import { OnboardingStoreName } from './index';
export const addDebugTools = ( context, modules ) => {
if ( ! context || ! context?.debug ) {
return;
}
context.dumpStore = async () => {
/* eslint-disable no-console */
if ( ! console?.groupCollapsed ) {
console.error( 'console.groupCollapsed is not supported.' );
return;
}
modules.forEach( ( module ) => {
const storeName = module.STORE_NAME;
const storeSelector = `wp.data.select( '${ storeName }' )`;
console.group( `[STORE] ${ storeSelector }` );
const dumpStore = ( selector ) => {
const contents = wp.data.select( storeName )[ selector ]();
console.groupCollapsed( `.${ selector }()` );
console.table( contents );
console.groupEnd();
};
Object.keys( module.selectors ).forEach( dumpStore );
console.groupEnd();
} );
/* eslint-enable no-console */
};
context.resetStore = () => {
const onboarding = wp.data.dispatch( OnboardingStoreName );
onboarding.reset();
onboarding.persist();
};
context.startOnboarding = () => {
const onboarding = wp.data.dispatch( OnboardingStoreName );
onboarding.setCompleted( false );
onboarding.setStep( 0 );
onboarding.persist();
};
};

View file

@ -1,7 +1,16 @@
import { STORE_NAME } from './constants';
import { initStore } from './store';
import { addDebugTools } from './debug';
import * as Onboarding from './onboarding';
import * as Common from './common';
initStore();
Onboarding.initStore();
Common.initStore();
export const WC_PAYPAL_STORE_NAME = STORE_NAME;
export * from './onboarding/hooks';
export const OnboardingHooks = Onboarding.hooks;
export const CommonHooks = Common.hooks;
export const OnboardingStoreName = Onboarding.STORE_NAME;
export const CommonStoreName = Common.STORE_NAME;
export * from './constants';
addDebugTools( window.ppcpSettings, [ Onboarding, Common ] );

View file

@ -1,19 +1,18 @@
export default {
RESET_ONBOARDING: 'RESET_ONBOARDING',
/**
* Action Types: Define unique identifiers for actions across all store modules.
*
* @file
*/
export default {
// Transient data.
SET_ONBOARDING_IS_READY: 'SET_ONBOARDING_IS_READY',
SET_IS_SAVING_ONBOARDING: 'SET_IS_SAVING_ONBOARDING',
SET_MANUAL_CONNECTION_BUSY: 'SET_MANUAL_CONNECTION_BUSY',
SET_TRANSIENT: 'ONBOARDING:SET_TRANSIENT',
// Persistent data.
SET_ONBOARDING_COMPLETED: 'SET_ONBOARDING_COMPLETED',
SET_ONBOARDING_DETAILS: 'SET_ONBOARDING_DETAILS',
SET_ONBOARDING_STEP: 'SET_ONBOARDING_STEP',
SET_SANDBOX_MODE: 'SET_SANDBOX_MODE',
SET_MANUAL_CONNECTION_MODE: 'SET_MANUAL_CONNECTION_MODE',
SET_CLIENT_ID: 'SET_CLIENT_ID',
SET_CLIENT_SECRET: 'SET_CLIENT_SECRET',
SET_IS_CASUAL_SELLER: 'SET_IS_CASUAL_SELLER',
SET_PRODUCTS: 'SET_PRODUCTS',
SET_PERSISTENT: 'ONBOARDING:SET_PERSISTENT',
RESET: 'ONBOARDING:RESET',
HYDRATE: 'ONBOARDING:HYDRATE',
// Controls - always start with "DO_".
DO_PERSIST_DATA: 'ONBOARDING:DO_PERSIST_DATA',
};

View file

@ -1,235 +1,103 @@
/**
* Action Creators: Define functions to create action objects.
*
* These functions update state or trigger side effects (e.g., async operations).
* Actions are categorized as Transient, Persistent, or Side effect.
*
* @file
*/
import { select } from '@wordpress/data';
import { apiFetch } from '@wordpress/data-controls';
import ACTION_TYPES from './action-types';
import { NAMESPACE, STORE_NAME } from '../constants';
import { STORE_NAME } from './constants';
/**
* @typedef {Object} Action An action object that is handled by a reducer or control.
* @property {string} type - The action type.
* @property {Object?} payload - Optional payload for the action.
*/
/**
* Special. Resets all values in the onboarding store to initial defaults.
*
* @return {{type: string}} The action.
* @return {Action} The action.
*/
export const resetOnboarding = () => {
return { type: ACTION_TYPES.RESET_ONBOARDING };
};
/**
* Non-persistent. Marks the onboarding details as "ready", i.e., fully initialized.
*
* @param {boolean} isReady
* @return {{type: string, isReady}} The action.
*/
export const setIsReady = ( isReady ) => {
return {
type: ACTION_TYPES.SET_ONBOARDING_IS_READY,
isReady,
};
};
/**
* Non-persistent. Changes the "saving" flag.
*
* @param {boolean} isSaving
* @return {{type: string, isSaving}} The action.
*/
export const setIsSaving = ( isSaving ) => {
return {
type: ACTION_TYPES.SET_IS_SAVING_ONBOARDING,
isSaving,
};
};
/**
* Non-persistent. Changes the "manual connection is busy" flag.
*
* @param {boolean} isBusy
* @return {{type: string, isBusy}} The action.
*/
export const setManualConnectionIsBusy = ( isBusy ) => {
return {
type: ACTION_TYPES.SET_MANUAL_CONNECTION_BUSY,
isBusy,
};
};
export const reset = () => ( { type: ACTION_TYPES.RESET } );
/**
* Persistent. Set the full onboarding details, usually during app initialization.
*
* @param {{data: {}, flags?: {}}} payload
* @return {{type: string, payload}} The action.
* @return {Action} The action.
*/
export const setOnboardingDetails = ( payload ) => {
return {
type: ACTION_TYPES.SET_ONBOARDING_DETAILS,
payload,
};
};
export const hydrate = ( payload ) => ( {
type: ACTION_TYPES.HYDRATE,
payload,
} );
/**
* Transient. Marks the onboarding details as "ready", i.e., fully initialized.
*
* @param {boolean} isReady
* @return {Action} The action.
*/
export const setIsReady = ( isReady ) => ( {
type: ACTION_TYPES.SET_TRANSIENT,
payload: { isReady },
} );
/**
* Persistent.Set the "onboarding completed" flag which shows or hides the wizard.
*
* @param {boolean} completed
* @return {{type: string, payload}} The action.
* @return {Action} The action.
*/
export const setCompleted = ( completed ) => {
return {
type: ACTION_TYPES.SET_ONBOARDING_COMPLETED,
completed,
};
};
export const setCompleted = ( completed ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { completed },
} );
/**
* Persistent. Sets the onboarding wizard to a new step.
*
* @param {number} step
* @return {{type: string, step}} An action.
* @return {Action} The action.
*/
export const setOnboardingStep = ( step ) => {
return {
type: ACTION_TYPES.SET_ONBOARDING_STEP,
step,
};
};
/**
* Persistent. Sets the sandbox mode on or off.
*
* @param {boolean} sandboxMode
* @return {{type: string, useSandbox}} An action.
*/
export const setSandboxMode = ( sandboxMode ) => {
return {
type: ACTION_TYPES.SET_SANDBOX_MODE,
useSandbox: sandboxMode,
};
};
/**
* Persistent. Toggles the "Manual Connection" mode on or off.
*
* @param {boolean} manualConnectionMode
* @return {{type: string, useManualConnection}} An action.
*/
export const setManualConnectionMode = ( manualConnectionMode ) => {
return {
type: ACTION_TYPES.SET_MANUAL_CONNECTION_MODE,
useManualConnection: manualConnectionMode,
};
};
/**
* Persistent. Changes the "client ID" value.
*
* @param {string} clientId
* @return {{type: string, clientId}} The action.
*/
export const setClientId = ( clientId ) => {
return {
type: ACTION_TYPES.SET_CLIENT_ID,
clientId,
};
};
/**
* Persistent. Changes the "client secret" value.
*
* @param {string} clientSecret
* @return {{type: string, clientSecret}} The action.
*/
export const setClientSecret = ( clientSecret ) => {
return {
type: ACTION_TYPES.SET_CLIENT_SECRET,
clientSecret,
};
};
export const setStep = ( step ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { step },
} );
/**
* Persistent. Sets the "isCasualSeller" value.
*
* @param {boolean} isCasualSeller
* @return {{type: string, isCasualSeller}} The action.
* @return {Action} The action.
*/
export const setIsCasualSeller = ( isCasualSeller ) => {
return {
type: ACTION_TYPES.SET_IS_CASUAL_SELLER,
isCasualSeller,
};
};
export const setIsCasualSeller = ( isCasualSeller ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { isCasualSeller },
} );
/**
* Persistent. Sets the "products" array.
*
* @param {string[]} products
* @return {{type: string, products}} The action.
* @return {Action} The action.
*/
export const setProducts = ( products ) => {
return {
type: ACTION_TYPES.SET_PRODUCTS,
products,
};
export const setProducts = ( products ) => ( {
type: ACTION_TYPES.SET_PERSISTENT,
payload: { products },
} );
/**
* Side effect. Triggers the persistence of onboarding data to the server.
*
* @return {Action} The action.
*/
export const persist = function* () {
const data = yield select( STORE_NAME ).persistentData();
yield { type: ACTION_TYPES.DO_PERSIST_DATA, data };
};
/**
* Attempts to establish a connection using client ID and secret via the server-side
* connection endpoint.
*
* @return {Object} The server response object
*/
export function* connectViaIdAndSecret() {
let result = null;
try {
const path = `${ NAMESPACE }/connect_manual`;
const { clientId, clientSecret, useSandbox } =
yield select( STORE_NAME ).getPersistentData();
yield setManualConnectionIsBusy( true );
result = yield apiFetch( {
path,
method: 'POST',
data: {
clientId,
clientSecret,
useSandbox,
},
} );
} catch ( e ) {
result = {
success: false,
error: e,
};
} finally {
yield setManualConnectionIsBusy( false );
}
return result;
}
/**
* Saves the persistent details to the WP database.
*
* @return {boolean} True, if the values were successfully saved.
*/
export function* persist() {
let error = null;
try {
const path = `${ NAMESPACE }/onboarding`;
const data = select( STORE_NAME ).getPersistentData();
yield setIsSaving( true );
yield apiFetch( {
path,
method: 'post',
data,
} );
} catch ( e ) {
error = e;
console.error( 'Error saving progress.', e );
} finally {
yield setIsSaving( false );
}
return error === null;
}

View file

@ -0,0 +1,28 @@
/**
* Name of the Redux store module.
*
* Used by: Reducer, Selector, Index
*
* @type {string}
*/
export const STORE_NAME = 'wc/paypal/onboarding';
/**
* REST path to hydrate data of this module by loading data from the WP DB..
*
* Used by: Resolvers
* See: OnboardingRestEndpoint.php
*
* @type {string}
*/
export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/onboarding';
/**
* REST path to persist data of this module to the WP DB.
*
* Used by: Controls
* See: OnboardingRestEndpoint.php
*
* @type {string}
*/
export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/onboarding';

View file

@ -0,0 +1,27 @@
/**
* Controls: Implement side effects, typically asynchronous operations.
*
* Controls use ACTION_TYPES keys as identifiers.
* They are triggered by corresponding actions and handle external interactions.
*
* @file
*/
import apiFetch from '@wordpress/api-fetch';
import { REST_PERSIST_PATH } from './constants';
import ACTION_TYPES from './action-types';
export const controls = {
async [ ACTION_TYPES.DO_PERSIST_DATA ]( { data } ) {
try {
await apiFetch( {
path: REST_PERSIST_PATH,
method: 'POST',
data,
} );
} catch ( e ) {
console.error( 'Error saving progress.', e );
}
},
};

View file

@ -1,163 +1,90 @@
/**
* Hooks: Provide the main API for components to interact with the store.
*
* These encapsulate store interactions, offering a consistent interface.
* Hooks simplify data access and manipulation for components.
*
* @file
*/
import { useSelect, useDispatch } from '@wordpress/data';
import apiFetch from '@wordpress/api-fetch';
import { NAMESPACE, PRODUCT_TYPES, STORE_NAME } from '../constants';
import { getFlags } from './selectors';
const useOnboardingDetails = () => {
const {
persist,
setOnboardingStep,
setCompleted,
setSandboxMode,
setManualConnectionMode,
setClientId,
setClientSecret,
setIsCasualSeller,
setProducts,
} = useDispatch( STORE_NAME );
import { PRODUCT_TYPES } from '../constants';
import { STORE_NAME } from './constants';
// Transient accessors.
const isSaving = useSelect( ( select ) => {
return select( STORE_NAME ).getTransientData().isSaving;
}, [] );
const useTransient = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).transientData()?.[ key ],
[ key ]
);
const isReady = useSelect( ( select ) => {
return select( STORE_NAME ).getTransientData().isReady;
} );
const usePersistent = ( key ) =>
useSelect(
( select ) => select( STORE_NAME ).persistentData()?.[ key ],
[ key ]
);
const isManualConnectionBusy = useSelect( ( select ) => {
return select( STORE_NAME ).getTransientData().isManualConnectionBusy;
}, [] );
const useHooks = () => {
const { persist, setStep, setCompleted, setIsCasualSeller, setProducts } =
useDispatch( STORE_NAME );
// Read-only flags.
const flags = useSelect( ( select ) => {
return select( STORE_NAME ).getFlags();
} );
const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] );
// Transient accessors.
const isReady = useTransient( 'isReady' );
// Persistent accessors.
const step = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().step || 0;
} );
const step = usePersistent( 'step' );
const completed = usePersistent( 'completed' );
const isCasualSeller = usePersistent( 'isCasualSeller' );
const products = usePersistent( 'products' );
const completed = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().completed;
} );
const clientId = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().clientId;
}, [] );
const clientSecret = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().clientSecret;
}, [] );
const isSandboxMode = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().useSandbox;
}, [] );
const isManualConnectionMode = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().useManualConnection;
}, [] );
const isCasualSeller = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().isCasualSeller;
}, [] );
const products = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().products || [];
}, [] );
const toggleProduct = ( list ) => {
const validProducts = list.filter( ( item ) =>
Object.values( PRODUCT_TYPES ).includes( item )
);
return setDetailAndPersist( setProducts, validProducts );
};
const setDetailAndPersist = async ( setter, value ) => {
const savePersistent = async ( setter, value ) => {
setter( value );
await persist();
};
return {
isSaving,
isReady,
isManualConnectionBusy,
step,
setStep: ( value ) => setDetailAndPersist( setOnboardingStep, value ),
completed,
setCompleted: ( state ) => setDetailAndPersist( setCompleted, state ),
isSandboxMode,
setSandboxMode: ( state ) =>
setDetailAndPersist( setSandboxMode, state ),
isManualConnectionMode,
setManualConnectionMode: ( state ) =>
setDetailAndPersist( setManualConnectionMode, state ),
clientId,
setClientId: ( value ) => setDetailAndPersist( setClientId, value ),
clientSecret,
setClientSecret: ( value ) =>
setDetailAndPersist( setClientSecret, value ),
isCasualSeller,
setIsCasualSeller: ( value ) =>
setDetailAndPersist( setIsCasualSeller, value ),
products,
toggleProduct,
flags,
isReady,
step,
setStep: ( value ) => {
return savePersistent( setStep, value );
},
completed,
setCompleted: ( state ) => {
return savePersistent( setCompleted, state );
},
isCasualSeller,
setIsCasualSeller: ( value ) => {
return savePersistent( setIsCasualSeller, value );
},
products,
setProducts: ( activeProducts ) => {
const validProducts = activeProducts.filter( ( item ) =>
Object.values( PRODUCT_TYPES ).includes( item )
);
return savePersistent( setProducts, validProducts );
},
};
};
export const useOnboardingStepWelcome = () => {
const {
isSaving,
isManualConnectionBusy,
isSandboxMode,
setSandboxMode,
isManualConnectionMode,
setManualConnectionMode,
clientId,
setClientId,
clientSecret,
setClientSecret,
} = useOnboardingDetails();
return {
isSaving,
isManualConnectionBusy,
isSandboxMode,
setSandboxMode,
isManualConnectionMode,
setManualConnectionMode,
clientId,
setClientId,
clientSecret,
setClientSecret,
};
};
export const useOnboardingStepBusiness = () => {
const { isCasualSeller, setIsCasualSeller } = useOnboardingDetails();
export const useBusiness = () => {
const { isCasualSeller, setIsCasualSeller } = useHooks();
return { isCasualSeller, setIsCasualSeller };
};
export const useOnboardingStepProducts = () => {
const { products, toggleProduct } = useOnboardingDetails();
export const useProducts = () => {
const { products, setProducts } = useHooks();
return { products, toggleProduct };
return { products, setProducts };
};
export const useOnboardingStep = () => {
const { isReady, step, setStep, completed, setCompleted, flags } =
useOnboardingDetails();
export const useSteps = () => {
const { flags, isReady, step, setStep, completed, setCompleted } =
useHooks();
return { isReady, step, setStep, completed, setCompleted, flags };
};
export const useManualConnect = () => {
const { connectViaIdAndSecret } = useDispatch( STORE_NAME );
return {
connectManual: connectViaIdAndSecret,
};
return { flags, isReady, step, setStep, completed, setCompleted };
};

View file

@ -1,6 +1,24 @@
import { createReduxStore, register } from '@wordpress/data';
import { controls as wpControls } from '@wordpress/data-controls';
import { STORE_NAME } from './constants';
import reducer from './reducer';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import * as hooks from './hooks';
import { resolvers } from './resolvers';
import { controls } from './controls';
export { reducer, selectors, actions, resolvers };
export const initStore = () => {
const store = createReduxStore( STORE_NAME, {
reducer,
controls: { ...wpControls, ...controls },
actions,
selectors,
resolvers,
} );
register( store );
};
export { hooks, selectors, STORE_NAME };

View file

@ -1,21 +1,19 @@
/**
* Reducer: Defines store structure and state updates for this module.
*
* Manages both transient (temporary) and persistent (saved) state.
* The initial state must define all properties, as dynamic additions are not supported.
*
* @file
*/
import { createReducer, createSetters } from '../utils';
import ACTION_TYPES from './action-types';
const defaultState = {
isReady: false,
isSaving: false,
isManualConnectionBusy: false,
// Store structure.
// Data persisted to the server.
data: {
completed: false,
step: 0,
useSandbox: false,
useManualConnection: false,
clientId: '',
clientSecret: '',
isCasualSeller: null, // null value will uncheck both options in the UI.
products: [],
},
const defaultTransient = {
isReady: false,
// Read only values, provided by the server.
flags: {
@ -25,83 +23,40 @@ const defaultState = {
},
};
export const onboardingReducer = (
state = defaultState,
{ type, ...action }
) => {
const setTransient = ( changes ) => {
const { data, ...transientChanges } = changes;
return { ...state, ...transientChanges };
};
const setPersistent = ( changes ) => {
const validChanges = Object.keys( changes ).reduce( ( acc, key ) => {
if ( key in defaultState.data ) {
acc[ key ] = changes[ key ];
}
return acc;
}, {} );
return {
...state,
data: { ...state.data, ...validChanges },
};
};
switch ( type ) {
// Reset store to initial state.
case ACTION_TYPES.RESET_ONBOARDING:
return setPersistent( defaultState.data );
// Transient data.
case ACTION_TYPES.SET_ONBOARDING_IS_READY:
return setTransient( { isReady: action.isReady } );
case ACTION_TYPES.SET_IS_SAVING_ONBOARDING:
return setTransient( { isSaving: action.isSaving } );
case ACTION_TYPES.SET_MANUAL_CONNECTION_BUSY:
return setTransient( { isManualConnectionBusy: action.isBusy } );
// Persistent data.
case ACTION_TYPES.SET_ONBOARDING_DETAILS:
const newState = setPersistent( action.payload.data );
if ( action.payload.flags ) {
newState.flags = { ...newState.flags, ...action.payload.flags };
}
return newState;
case ACTION_TYPES.SET_ONBOARDING_COMPLETED:
return setPersistent( { completed: action.completed } );
case ACTION_TYPES.SET_CLIENT_ID:
return setPersistent( { clientId: action.clientId } );
case ACTION_TYPES.SET_CLIENT_SECRET:
return setPersistent( { clientSecret: action.clientSecret } );
case ACTION_TYPES.SET_ONBOARDING_STEP:
return setPersistent( { step: action.step } );
case ACTION_TYPES.SET_SANDBOX_MODE:
return setPersistent( { useSandbox: action.useSandbox } );
case ACTION_TYPES.SET_MANUAL_CONNECTION_MODE:
return setPersistent( {
useManualConnection: action.useManualConnection,
} );
case ACTION_TYPES.SET_IS_CASUAL_SELLER:
return setPersistent( { isCasualSeller: action.isCasualSeller } );
case ACTION_TYPES.SET_PRODUCTS:
return setPersistent( { products: action.products } );
default:
return state;
}
const defaultPersistent = {
completed: false,
step: 0,
isCasualSeller: null, // null value will uncheck both options in the UI.
products: [],
};
// Reducer logic.
const [ setTransient, setPersistent ] = createSetters(
defaultTransient,
defaultPersistent
);
const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) =>
setTransient( state, payload ),
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) =>
setPersistent( state, payload ),
[ ACTION_TYPES.RESET ]: ( state ) =>
setPersistent( state, defaultPersistent ),
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
const newState = setPersistent( state, payload.data );
// Flags are not updated by `setPersistent()`.
if ( payload.flags ) {
newState.flags = { ...newState.flags, ...payload.flags };
}
return newState;
},
} );
export default onboardingReducer;

View file

@ -1,25 +1,36 @@
/**
* Resolvers: Handle asynchronous data fetching for the store.
*
* These functions update store state with data from external sources.
* Each resolver corresponds to a specific selector (selector with same name must exist).
* Resolvers are called automatically when selectors request unavailable data.
*
* @file
*/
import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { apiFetch } from '@wordpress/data-controls';
import { NAMESPACE } from '../constants';
import { setIsReady, setOnboardingDetails } from './actions';
/**
* Retrieve settings from the site's REST API.
*/
export function* getPersistentData() {
const path = `${ NAMESPACE }/onboarding`;
import { STORE_NAME, REST_HYDRATE_PATH } from './constants';
try {
const result = yield apiFetch( { path } );
yield setOnboardingDetails( result );
yield setIsReady( true );
} catch ( e ) {
yield dispatch( 'core/notices' ).createErrorNotice(
__(
'Error retrieving onboarding details.',
'woocommerce-paypal-payments'
)
);
}
}
export const resolvers = {
/**
* Retrieve settings from the site's REST API.
*/
*persistentData() {
try {
const result = yield apiFetch( { path: REST_HYDRATE_PATH } );
yield dispatch( STORE_NAME ).hydrate( result );
yield dispatch( STORE_NAME ).setIsReady( true );
} catch ( e ) {
yield dispatch( 'core/notices' ).createErrorNotice(
__(
'Error retrieving onboarding details.',
'woocommerce-paypal-payments'
)
);
}
},
};

View file

@ -1,22 +1,25 @@
/**
* Selectors: Extract specific pieces of state from the store.
*
* These functions provide a consistent interface for accessing store data.
* They allow components to retrieve data without knowing the store structure.
*
* @file
*/
const EMPTY_OBJ = Object.freeze( {} );
const getOnboardingState = ( state ) => {
if ( ! state ) {
return EMPTY_OBJ;
}
const getState = ( state ) => state || EMPTY_OBJ;
return state.onboarding || EMPTY_OBJ;
export const persistentData = ( state ) => {
return getState( state ).data || EMPTY_OBJ;
};
export const getPersistentData = ( state ) => {
return getOnboardingState( state ).data || EMPTY_OBJ;
};
export const getTransientData = ( state ) => {
const { data, flags, ...transientState } = getOnboardingState( state );
export const transientData = ( state ) => {
const { data, flags, ...transientState } = getState( state );
return transientState || EMPTY_OBJ;
};
export const getFlags = ( state ) => {
return getOnboardingState( state ).flags || EMPTY_OBJ;
export const flags = ( state ) => {
return getState( state ).flags || EMPTY_OBJ;
};

View file

@ -1,58 +0,0 @@
import { createReduxStore, register, combineReducers } from '@wordpress/data';
import { controls } from '@wordpress/data-controls';
import { STORE_NAME } from './constants';
import * as onboarding from './onboarding';
const actions = {};
const selectors = {};
const resolvers = {};
[ onboarding ].forEach( ( item ) => {
Object.assign( actions, { ...item.actions } );
Object.assign( selectors, { ...item.selectors } );
Object.assign( resolvers, { ...item.resolvers } );
} );
const reducer = combineReducers( {
onboarding: onboarding.reducer,
} );
export const initStore = () => {
const store = createReduxStore( STORE_NAME, {
reducer,
controls,
actions,
selectors,
resolvers,
} );
register( store );
/* eslint-disable no-console */
// Provide a debug tool to inspect the Redux store via the JS console.
if ( window.ppcpSettings?.debug && console?.groupCollapsed ) {
window.ppcpSettings.dumpStore = () => {
const storeSelector = `wp.data.select('${ STORE_NAME }')`;
console.group( `[STORE] ${ storeSelector }` );
const storeState = wp.data.select( STORE_NAME );
Object.keys( selectors ).forEach( ( selector ) => {
console.groupCollapsed( `[SELECTOR] .${ selector }()` );
console.table( storeState[ selector ]() );
console.groupEnd();
} );
console.groupEnd();
};
window.ppcpSettings.resetStore = () => {
wp.data.dispatch( STORE_NAME ).resetOnboarding();
wp.data.dispatch( STORE_NAME ).persist();
};
window.ppcpSettings.startOnboarding = () => {
wp.data.dispatch( STORE_NAME ).setCompleted( false );
wp.data.dispatch( STORE_NAME ).setOnboardingStep( 0 );
wp.data.dispatch( STORE_NAME ).persist();
};
}
/* eslint-enable no-console */
};

View file

@ -0,0 +1,75 @@
/**
* Updates an object with new values, filtering based on allowed keys.
*
* Helper method used by createSetters.
*
* @param {Object} oldObject The original object to update.
* @param {Object} newValues The new values to apply.
* @param {Object} allowedKeys An object whose keys define the allowed keys to update.
* @return {Object} A new object with the allowed updates applied.
*/
const updateObject = ( oldObject, newValues, allowedKeys = {} ) => ( {
...oldObject,
...Object.keys( newValues ).reduce( ( acc, key ) => {
if ( key in allowedKeys ) {
acc[ key ] = newValues[ key ];
}
return acc;
}, {} ),
} );
/**
* Creates setter functions for updating state.
*
* Only properties that are present in the "defaultTransient" or "defaultPersistent"
* arguments can be updated by the setters. Make sure that the default state defines
* ALL possible properties.
*
* @param {Object} defaultTransient Object defining initial transient values.
* @param {Object} defaultPersistent Object defining initial persistent values.
* @return {[Function, Function]} An array containing setTransient and setPersistent functions.
*/
export const createSetters = ( defaultTransient, defaultPersistent ) => {
const setTransient = ( oldState, newValues = {} ) =>
updateObject( oldState, newValues, defaultTransient );
const setPersistent = ( oldState, newValues = {} ) => ( {
...oldState,
data: updateObject( oldState.data, newValues, defaultPersistent ),
} );
return [ setTransient, setPersistent ];
};
/**
* Creates a reducer function with predefined action handlers.
*
* @param {Object} defaultTransient Object defining initial transient values.
* @param {Object} defaultPersistent Object defining initial persistent values.
* @param {Object} handlers An object mapping action types to handler functions.
* @return {Function} A reducer function.
*/
export const createReducer = (
defaultTransient,
defaultPersistent,
handlers
) => {
if ( Object.hasOwnProperty.call( defaultTransient, 'data' ) ) {
throw new Error(
'The transient state cannot contain a "data" property.'
);
}
const initialState = {
...defaultTransient,
data: defaultPersistent,
};
return function reducer( state = initialState, action ) {
if ( Object.hasOwnProperty.call( handlers, action.type ) ) {
return handlers[ action.type ]( state, action.payload ?? {} );
}
return state;
};
};

View file

@ -0,0 +1,42 @@
/**
* Opens the provided URL, preferably in a popup window.
*
* Popups are usually only supported on desktop devices, when the browser is not in fullscreen mode.
*
* @param {string} url
* @param {Object} options
* @param {string} options.name
* @param {number} options.width
* @param {number} options.height
* @param {boolean} options.resizeable
* @return {null|Window} Popup window instance, or null.
*/
export const openPopup = (
url,
{ name = '_blank', width = 450, height = 720, resizeable = false } = {}
) => {
width = Math.max( 100, Math.min( window.screen.width - 40, width ) );
height = Math.max( 100, Math.min( window.screen.height - 40, height ) );
const left = ( window.screen.width - width ) / 2;
const top = ( window.screen.height - height ) / 2;
const features = [
`width=${ width }`,
`height=${ height }`,
`left=${ left }`,
`top=${ top }`,
`resizable=${ resizeable ? 'yes' : 'no' }`,
`scrollbars=yes`,
`status=no`,
];
const popup = window.open( url, name, features.join( ',' ) );
if ( popup && ! popup.closed ) {
popup.focus();
return popup;
}
return null;
};

View file

@ -9,11 +9,16 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings;
use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings;
use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile;
use WooCommerce\PayPalCommerce\Settings\Endpoint\CommonRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\LoginLinkRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
return array(
'settings.url' => static function ( ContainerInterface $container ) : string {
@ -44,9 +49,15 @@ return array(
$can_use_card_payments
);
},
'settings.data.common' => static function ( ContainerInterface $container ) : CommonSettings {
return new CommonSettings();
},
'settings.rest.onboarding' => static function ( ContainerInterface $container ) : OnboardingRestEndpoint {
return new OnboardingRestEndpoint( $container->get( 'settings.data.onboarding' ) );
},
'settings.rest.common' => static function ( ContainerInterface $container ) : CommonRestEndpoint {
return new CommonRestEndpoint( $container->get( 'settings.data.common' ) );
},
'settings.rest.connect_manual' => static function ( ContainerInterface $container ) : ConnectManualRestEndpoint {
return new ConnectManualRestEndpoint(
$container->get( 'api.paypal-host-production' ),
@ -54,6 +65,11 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'settings.rest.login_link' => static function ( ContainerInterface $container ) : LoginLinkRestEndpoint {
return new LoginLinkRestEndpoint(
$container->get( 'settings.service.connection-url-generators' ),
);
},
'settings.casual-selling.supported-countries' => static function ( ContainerInterface $container ) : array {
return array(
'AR',
@ -110,6 +126,35 @@ return array(
return in_array( $country, $eligible_countries, true );
},
'settings.service.signup-link-cache' => static function ( ContainerInterface $container ) : Cache {
return new Cache( 'ppcp-paypal-signup-link' );
},
'settings.service.connection-url-generators' => static function ( ContainerInterface $container ) : array {
// Define available environments.
$environments = array(
'production' => array(
'partner_referrals' => $container->get( 'api.endpoint.partner-referrals-production' ),
),
'sandbox' => array(
'partner_referrals' => $container->get( 'api.endpoint.partner-referrals-sandbox' ),
),
);
$generators = array();
// Instantiate URL generators for each environment.
foreach ( $environments as $environment => $config ) {
$generators[ $environment ] = new ConnectionUrlGenerator(
$config['partner_referrals'],
$container->get( 'api.repository.partner-referrals-data' ),
$container->get( 'settings.service.signup-link-cache' ),
$environment,
$container->get( 'woocommerce.logger.woocommerce' )
);
}
return $generators;
},
'settings.switch-ui.endpoint' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint {
return new SwitchSettingsUiEndpoint(
$container->get( 'woocommerce.logger.woocommerce' ),

View file

@ -0,0 +1,119 @@
<?php
/**
* Common settings class
*
* @package WooCommerce\PayPalCommerce\Settings\Data
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Data;
use RuntimeException;
/**
* Class CommonSettings
*
* This class serves as a container for managing the common settings that
* are used and managed in various areas of the settings UI
*
* Those settings mainly describe connection details and are initially collected
* in the onboarding wizard, and also appear in the settings screen.
*/
class CommonSettings extends AbstractDataModel {
/**
* Option key where profile details are stored.
*
* @var string
*/
protected const OPTION_KEY = 'woocommerce-ppcp-data-common';
/**
* Get default values for the model.
*
* @return array
*/
protected function get_defaults() : array {
return array(
'use_sandbox' => false,
'use_manual_connection' => false,
'client_id' => '',
'client_secret' => '',
);
}
// -----
/**
* Gets the 'use sandbox' setting.
*
* @return bool
*/
public function get_sandbox() : bool {
return (bool) $this->data['use_sandbox'];
}
/**
* Sets the 'use sandbox' setting.
*
* @param bool $use_sandbox Whether to use sandbox mode.
*/
public function set_sandbox( bool $use_sandbox ) : void {
$this->data['use_sandbox'] = $use_sandbox;
}
/**
* Gets the 'use manual connection' setting.
*
* @return bool
*/
public function get_manual_connection() : bool {
return (bool) $this->data['use_manual_connection'];
}
/**
* Sets the 'use manual connection' setting.
*
* @param bool $use_manual_connection Whether to use manual connection.
*/
public function set_manual_connection( bool $use_manual_connection ) : void {
$this->data['use_manual_connection'] = $use_manual_connection;
}
/**
* Gets the client ID.
*
* @return string
*/
public function get_client_id() : string {
return $this->data['client_id'];
}
/**
* Sets the client ID.
*
* @param string $client_id The client ID.
*/
public function set_client_id( string $client_id ) : void {
$this->data['client_id'] = sanitize_text_field( $client_id );
}
/**
* Gets the client secret.
*
* @return string
*/
public function get_client_secret() : string {
return $this->data['client_secret'];
}
/**
* Sets the client secret.
*
* @param string $client_secret The client secret.
*/
public function set_client_secret( string $client_secret ) : void {
$this->data['client_secret'] = sanitize_text_field( $client_secret );
}
}

View file

@ -64,14 +64,10 @@ class OnboardingProfile extends AbstractDataModel {
*/
protected function get_defaults() : array {
return array(
'completed' => false,
'step' => 0,
'use_sandbox' => false,
'use_manual_connection' => false,
'client_id' => '',
'client_secret' => '',
'is_casual_seller' => null,
'products' => array(),
'completed' => false,
'step' => 0,
'is_casual_seller' => null,
'products' => array(),
);
}
@ -113,78 +109,6 @@ class OnboardingProfile extends AbstractDataModel {
$this->data['step'] = $step;
}
/**
* Gets the 'use sandbox' setting.
*
* @return bool
*/
public function get_sandbox() : bool {
return (bool) $this->data['use_sandbox'];
}
/**
* Sets the 'use sandbox' setting.
*
* @param bool $use_sandbox Whether to use sandbox mode.
*/
public function set_sandbox( bool $use_sandbox ) : void {
$this->data['use_sandbox'] = $use_sandbox;
}
/**
* Gets the 'use manual connection' setting.
*
* @return bool
*/
public function get_manual_connection() : bool {
return (bool) $this->data['use_manual_connection'];
}
/**
* Sets the 'use manual connection' setting.
*
* @param bool $use_manual_connection Whether to use manual connection.
*/
public function set_manual_connection( bool $use_manual_connection ) : void {
$this->data['use_manual_connection'] = $use_manual_connection;
}
/**
* Gets the client ID.
*
* @return string
*/
public function get_client_id() : string {
return $this->data['client_id'];
}
/**
* Sets the client ID.
*
* @param string $client_id The client ID.
*/
public function set_client_id( string $client_id ) : void {
$this->data['client_id'] = sanitize_text_field( $client_id );
}
/**
* Gets the client secret.
*
* @return string
*/
public function get_client_secret() : string {
return $this->data['client_secret'];
}
/**
* Sets the client secret.
*
* @param string $client_secret The client secret.
*/
public function set_client_secret( string $client_secret ) : void {
$this->data['client_secret'] = sanitize_text_field( $client_secret );
}
/**
* Gets the casual seller flag.
*

View file

@ -0,0 +1,133 @@
<?php
/**
* REST endpoint to manage the common module.
*
* @package WooCommerce\PayPalCommerce\Settings\Endpoint
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Endpoint;
use WP_REST_Server;
use WP_REST_Response;
use WP_REST_Request;
use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings;
/**
* REST controller for "common" settings, which are used and modified by
* multiple components. Those settings mainly define connection details.
*
* This API acts as the intermediary between the "external world" and our
* internal data model.
*/
class CommonRestEndpoint extends RestEndpoint {
/**
* The base path for this REST controller.
*
* @var string
*/
protected string $rest_base = 'common';
/**
* The settings instance.
*
* @var CommonSettings
*/
protected CommonSettings $settings;
/**
* Field mapping for request to profile transformation.
*
* @var array
*/
private array $field_map = array(
'use_sandbox' => array(
'js_name' => 'useSandbox',
'sanitize' => 'to_boolean',
),
'use_manual_connection' => array(
'js_name' => 'useManualConnection',
'sanitize' => 'to_boolean',
),
'client_id' => array(
'js_name' => 'clientId',
'sanitize' => 'sanitize_text_field',
),
'client_secret' => array(
'js_name' => 'clientSecret',
'sanitize' => 'sanitize_text_field',
),
);
/**
* Constructor.
*
* @param CommonSettings $settings The settings instance.
*/
public function __construct( CommonSettings $settings ) {
$this->settings = $settings;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_details' ),
'permission_callback' => array( $this, 'check_permission' ),
),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_details' ),
'permission_callback' => array( $this, 'check_permission' ),
),
)
);
}
/**
* Returns all common details from the DB.
*
* @return WP_REST_Response The common settings.
*/
public function get_details() : WP_REST_Response {
$js_data = $this->sanitize_for_javascript(
$this->settings->to_array(),
$this->field_map
);
return $this->return_success( $js_data );
}
/**
* Updates common details based on the request.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response The new common settings.
*/
public function update_details( WP_REST_Request $request ) : WP_REST_Response {
$wp_data = $this->sanitize_for_wordpress(
$request->get_params(),
$this->field_map
);
$this->settings->from_array( $wp_data );
$this->settings->save();
return $this->get_details();
}
}

View file

@ -78,9 +78,9 @@ class ConnectManualRestEndpoint extends RestEndpoint {
/**
* ConnectManualRestEndpoint constructor.
*
* @param string $live_host The API host for the live mode.
* @param string $live_host The API host for the live mode.
* @param string $sandbox_host The API host for the sandbox mode.
* @param LoggerInterface $logger The logger.
* @param LoggerInterface $logger The logger.
*/
public function __construct(
string $live_host,
@ -126,47 +126,38 @@ class ConnectManualRestEndpoint extends RestEndpoint {
$use_sandbox = (bool) ( $data['use_sandbox'] ?? false );
if ( empty( $client_id ) || empty( $client_secret ) ) {
return rest_ensure_response(
array(
'success' => false,
'message' => 'No client ID or secret provided.',
)
);
return $this->return_error( 'No client ID or secret provided.' );
}
try {
$payee = $this->request_payee( $client_id, $client_secret, $use_sandbox );
} catch ( Exception $exception ) {
return rest_ensure_response(
array(
'success' => false,
'message' => $exception->getMessage(),
)
);
return $this->return_error( $exception->getMessage() );
}
$result = array(
'merchantId' => $payee->merchant_id,
'email' => $payee->email_address,
'success' => true,
return $this->return_success(
array(
'merchantId' => $payee->merchant_id,
'email' => $payee->email_address,
'success' => true,
)
);
return rest_ensure_response( $result );
}
/**
* Retrieves the payee object with the merchant data
* by creating a minimal PayPal order.
*
* @param string $client_id The client ID.
* @param string $client_secret The client secret.
* @param bool $use_sandbox Whether to use the sandbox mode.
* @return stdClass The payee object.
* @throws Exception When failed to retrieve payee.
*
* phpcs:disable Squiz.Commenting
* phpcs:disable Generic.Commenting
*
* @param string $client_secret The client secret.
* @param bool $use_sandbox Whether to use the sandbox mode.
* @param string $client_id The client ID.
*
* @return stdClass The payee object.
*/
private function request_payee(
string $client_id,
@ -176,24 +167,13 @@ class ConnectManualRestEndpoint extends RestEndpoint {
$host = $use_sandbox ? $this->sandbox_host : $this->live_host;
$empty_settings = new class() implements ContainerInterface
{
public function get( string $id ) {
throw new NotFoundException();
}
public function has( string $id ) {
return false;
}
};
$bearer = new PayPalBearer(
new InMemoryCache(),
$host,
$client_id,
$client_secret,
$this->logger,
$empty_settings
null
);
$orders = new Orders(

View file

@ -0,0 +1,105 @@
<?php
/**
* REST endpoint to manage the onboarding module.
*
* @package WooCommerce\PayPalCommerce\Settings\Endpoint
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Endpoint;
use WP_REST_Server;
use WP_REST_Response;
use WP_REST_Request;
use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator;
/**
* REST controller that generates merchant login URLs.
*/
class LoginLinkRestEndpoint extends RestEndpoint {
/**
* The base path for this REST controller.
*
* @var string
*/
protected string $rest_base = 'login_link';
/**
* Link generator list, with environment name as array key.
*
* @var ConnectionUrlGenerator[]
*/
protected array $url_generators;
/**
* Constructor.
*
* @param ConnectionUrlGenerator[] $url_generators Array of environment-specific URL generators.
*/
public function __construct( array $url_generators ) {
$this->url_generators = $url_generators;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'get_login_url' ),
'permission_callback' => array( $this, 'check_permission' ),
'args' => array(
'environment' => array(
'required' => true,
'type' => 'string',
),
'products' => array(
'required' => true,
'type' => 'array',
'items' => array(
'type' => 'string',
),
'sanitize_callback' => function ( $products ) {
return array_map( 'sanitize_text_field', $products );
},
),
),
),
)
);
}
/**
* Returns the full login URL for the requested environment and products.
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The login URL or an error response.
*/
public function get_login_url( WP_REST_Request $request ) : WP_REST_Response {
$environment = $request->get_param( 'environment' );
$products = $request->get_param( 'products' );
if ( ! isset( $this->url_generators[ $environment ] ) ) {
return new WP_REST_Response(
array( 'error' => 'Invalid environment specified.' ),
400
);
}
$url_generator = $this->url_generators[ $environment ];
try {
$url = $url_generator->generate( $products );
return $this->return_success( $url );
} catch ( \Exception $e ) {
return $this->return_error( $e->getMessage() );
}
}
}

View file

@ -41,35 +41,19 @@ class OnboardingRestEndpoint extends RestEndpoint {
* @var array
*/
private array $field_map = array(
'completed' => array(
'completed' => array(
'js_name' => 'completed',
'sanitize' => 'to_boolean',
),
'step' => array(
'step' => array(
'js_name' => 'step',
'sanitize' => 'to_number',
),
'use_sandbox' => array(
'js_name' => 'useSandbox',
'sanitize' => 'to_boolean',
),
'use_manual_connection' => array(
'js_name' => 'useManualConnection',
'sanitize' => 'to_boolean',
),
'client_id' => array(
'js_name' => 'clientId',
'sanitize' => 'sanitize_text_field',
),
'client_secret' => array(
'js_name' => 'clientSecret',
'sanitize' => 'sanitize_text_field',
),
'is_casual_seller' => array(
'is_casual_seller' => array(
'js_name' => 'isCasualSeller',
'sanitize' => 'to_boolean',
),
'products' => array(
'products' => array(
'js_name' => 'products',
),
);
@ -147,9 +131,9 @@ class OnboardingRestEndpoint extends RestEndpoint {
$this->flag_map
);
return rest_ensure_response(
return $this->return_success(
$js_data,
array(
'data' => $js_data,
'flags' => $js_flags,
)
);

View file

@ -10,14 +10,12 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Endpoint;
use WC_REST_Controller;
use WP_REST_Response;
/**
* Base class for REST controllers in the settings module.
*
* This is a base class for specific REST endpoints; do not instantiate this
* class directly.
*/
class RestEndpoint extends WC_REST_Controller {
abstract class RestEndpoint extends WC_REST_Controller {
/**
* Endpoint namespace.
*
@ -34,6 +32,54 @@ class RestEndpoint extends WC_REST_Controller {
return current_user_can( 'manage_woocommerce' );
}
/**
* Returns a successful REST API response.
*
* @param mixed $data The main response data.
* @param array $extra Optional, additional response data.
*
* @return WP_REST_Response The successful response.
*/
protected function return_success( $data, array $extra = array() ) : WP_REST_Response {
$response = array(
'success' => true,
'data' => $data,
);
if ( $extra ) {
foreach ( $extra as $key => $value ) {
if ( isset( $response[ $key ] ) ) {
continue;
}
$response[ $key ] = $value;
}
}
return rest_ensure_response( $response );
}
/**
* Returns an error REST API response.
*
* @param string $reason The reason for the error.
* @param mixed $details Optional details about the error.
*
* @return WP_REST_Response The error response.
*/
protected function return_error( string $reason, $details = null ) : WP_REST_Response {
$response = array(
'success' => false,
'message' => $reason,
);
if ( ! is_null( $details ) ) {
$response['details'] = $details;
}
return rest_ensure_response( $response );
}
/**
* Sanitizes parameters based on a field mapping.
*

View file

@ -15,6 +15,8 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData;
/**
* Class SwitchSettingsUiEndpoint
*
* Note: This is an ajax handler, not a REST endpoint
*/
class SwitchSettingsUiEndpoint {

View file

@ -0,0 +1,227 @@
<?php
/**
* Generator service to build URLs to sign in to a PayPal account.
*
* @package WooCommerce\PayPalCommerce\Settings\Service
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Service;
use Exception;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData;
use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl;
use WooCommerce\WooCommerce\Logging\Logger\NullLogger;
/**
* Generator that builds the ISU connection URL.
*/
class ConnectionUrlGenerator {
/**
* The partner referrals endpoint.
*
* @var PartnerReferrals
*/
protected PartnerReferrals $partner_referrals;
/**
* The default partner referrals data.
*
* @var PartnerReferralsData
*/
protected PartnerReferralsData $referrals_data;
/**
* The cache
*
* @var Cache
*/
protected Cache $cache;
/**
* Which environment is used for the connection URL.
*
* @var string
*/
protected string $environment = '';
/**
* The logger
*
* @var LoggerInterface
*/
private $logger;
/**
* Constructor for the ConnectionUrlGenerator class.
*
* Initializes the cache and logger properties of the class.
*
* @param PartnerReferrals $partner_referrals PartnerReferrals for URL generation.
* @param PartnerReferralsData $referrals_data Default partner referrals data.
* @param Cache $cache The cache object used for storing and
* retrieving data.
* @param string $environment Environment that is used to generate the URL.
* ['production'|'sandbox'].
* @param ?LoggerInterface $logger The logger object for logging messages.
*/
public function __construct(
PartnerReferrals $partner_referrals,
PartnerReferralsData $referrals_data,
Cache $cache,
string $environment,
?LoggerInterface $logger = null
) {
$this->partner_referrals = $partner_referrals;
$this->referrals_data = $referrals_data;
$this->cache = $cache;
$this->environment = $environment;
$this->logger = $logger ?: new NullLogger();
}
/**
* Returns the environment for which the URL is being generated.
*
* @return string
*/
public function environment() : string {
return $this->environment;
}
/**
* Generates a PayPal onboarding URL for merchant sign-up.
*
* This function creates a URL for merchants to sign up for PayPal services.
* It handles caching of the URL, generation of new URLs when necessary,
* and works for both production and sandbox environments.
*
* @param array $products An array of product identifiers to include in the sign-up process.
* These determine the PayPal onboarding experience.
*
* @return string The generated PayPal onboarding URL.
*/
public function generate( array $products = array() ) : string {
$cache_key = $this->cache_key( $products );
$user_id = get_current_user_id();
$onboarding_url = new OnboardingUrl( $this->cache, $cache_key, $user_id );
$cached_url = $this->try_get_from_cache( $onboarding_url, $cache_key );
if ( $cached_url ) {
$this->logger->info( 'Using cached onboarding URL for: ' . $cache_key );
return $cached_url;
}
$this->logger->info( 'Generating onboarding URL for: ' . $cache_key );
$url = $this->generate_new_url( $products, $onboarding_url, $cache_key );
if ( $url ) {
$this->persist_url( $onboarding_url, $url );
}
return $url;
}
/**
* Generates a cache key from the environment and sorted product array.
*
* @param array $products Product identifiers that are part of the cache key.
*
* @return string The cache key, defining the product list and environment.
*/
protected function cache_key( array $products = array() ) : string {
// Sort products alphabetically, to improve cache implementation.
sort( $products );
return $this->environment() . '-' . implode( '-', $products );
}
/**
* Attempts to load the URL from cache.
*
* @param OnboardingUrl $onboarding_url The OnboardingUrl object.
* @param string $cache_key The cache key.
*
* @return string The cached URL, or an empty string if no URL is found.
*/
protected function try_get_from_cache( OnboardingUrl $onboarding_url, string $cache_key ) : string {
try {
if ( $onboarding_url->load() ) {
$this->logger->debug( 'Loaded onboarding URL from cache: ' . $cache_key );
return $onboarding_url->get();
}
} catch ( Exception $e ) {
// No problem, return an empty string to generate a new URL.
$this->logger->warning( 'Failed to load onboarding URL from cache: ' . $cache_key );
}
return '';
}
/**
* Generates a new URL.
*
* @param array $products The products array.
* @param OnboardingUrl $onboarding_url The OnboardingUrl object.
* @param string $cache_key The cache key.
*
* @return string The generated URL or an empty string on failure.
*/
protected function generate_new_url( array $products, OnboardingUrl $onboarding_url, string $cache_key ) : string {
$query_args = array( 'displayMode' => 'minibrowser' );
$onboarding_url->init();
try {
$onboarding_token = $onboarding_url->token();
} catch ( Exception $e ) {
$this->logger->warning( 'Could not generate an onboarding token for: ' . $cache_key );
return '';
}
$data = $this->prepare_referral_data( $products, $onboarding_token );
try {
$url = $this->partner_referrals->signup_link( $data );
} catch ( Exception $e ) {
$this->logger->warning( 'Could not generate an onboarding URL for: ' . $cache_key );
return '';
}
return add_query_arg( $query_args, $url );
}
/**
* Prepares the referral data.
*
* @param array $products The products array.
* @param string $onboarding_token The onboarding token.
*
* @return array The prepared referral data.
*/
protected function prepare_referral_data( array $products, string $onboarding_token ) : array {
$data = $this->referrals_data
->with_products( $products )
->data();
return $this->referrals_data->append_onboarding_token( $data, $onboarding_token );
}
/**
* Persists the generated URL.
*
* @param OnboardingUrl $onboarding_url The OnboardingUrl object.
* @param string $url The URL to persist.
*/
protected function persist_url( OnboardingUrl $onboarding_url, string $url ) : void {
$onboarding_url->set( $url );
$onboarding_url->persist();
}
}

View file

@ -9,8 +9,7 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings;
use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
@ -26,7 +25,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
/**
* Returns whether the old settings UI should be loaded.
*/
public static function should_use_the_old_ui(): bool {
public static function should_use_the_old_ui() : bool {
return apply_filters(
'woocommerce_paypal_payments_should_use_the_old_ui',
(bool) get_option( SwitchSettingsUiEndpoint::OPTION_NAME_SHOULD_USE_OLD_UI ) === true
@ -89,7 +88,13 @@ class SettingsModule implements ServiceModule, ExecutableModule {
$endpoint = $container->get( 'settings.switch-ui.endpoint' );
assert( $endpoint instanceof SwitchSettingsUiEndpoint );
add_action( 'wc_ajax_' . SwitchSettingsUiEndpoint::ENDPOINT, array( $endpoint, 'handle_request' ) );
add_action(
'wc_ajax_' . SwitchSettingsUiEndpoint::ENDPOINT,
array(
$endpoint,
'handle_request',
)
);
return true;
}
@ -170,13 +175,17 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_action(
'rest_api_init',
static function () use ( $container ) : void {
$onboarding_endpoint = $container->get( 'settings.rest.onboarding' );
assert( $onboarding_endpoint instanceof OnboardingRestEndpoint );
$onboarding_endpoint->register_routes();
$endpoints = array(
$container->get( 'settings.rest.onboarding' ),
$container->get( 'settings.rest.common' ),
$container->get( 'settings.rest.connect_manual' ),
$container->get( 'settings.rest.login_link' ),
);
$connect_manual_endpoint = $container->get( 'settings.rest.connect_manual' );
assert( $connect_manual_endpoint instanceof ConnectManualRestEndpoint );
$connect_manual_endpoint->register_routes();
foreach ( $endpoints as $endpoint ) {
assert( $endpoint instanceof RestEndpoint );
$endpoint->register_routes();
}
}
);

View file

@ -2671,9 +2671,9 @@
mime "^3.0.0"
web-vitals "^4.2.1"
"@wordpress/element@^6.1.0":
"@wordpress/element@*", "@wordpress/element@^6.1.0":
version "6.11.0"
resolved "https://registry.npmjs.org/@wordpress/element/-/element-6.11.0.tgz"
resolved "https://registry.yarnpkg.com/@wordpress/element/-/element-6.11.0.tgz#7bc3e453a95bb806a707b4dc617373afa108af19"
integrity sha512-UvHFYkT+EEaXEyEfw+iqLHRO9OwBjjsUydEMHcqntzkNcsYeAbmaL9V8R9ikXHLe6ftdbkwoXgF85xVPhVsL+Q==
dependencies:
"@babel/runtime" "7.25.7"
@ -2715,6 +2715,15 @@
globals "^13.12.0"
requireindex "^1.2.0"
"@wordpress/icons@^10.11.0":
version "10.11.0"
resolved "https://registry.yarnpkg.com/@wordpress/icons/-/icons-10.11.0.tgz#0beedef8ee49c135412fb81fc59440dd48d652aa"
integrity sha512-RMetpFwUIeh3sVj2+p6+QX5AW8pF7DvQzxH9jUr8YjaF2iLE64vy6m0cZz/X8xkSktHrXMuPJIr7YIVF20TEyw==
dependencies:
"@babel/runtime" "7.25.7"
"@wordpress/element" "*"
"@wordpress/primitives" "*"
"@wordpress/jest-console@*":
version "8.11.0"
resolved "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.11.0.tgz"
@ -2749,6 +2758,15 @@
resolved "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.11.0.tgz"
integrity sha512-Aoc8+xWOyiXekodjaEjS44z85XK877LzHZqsQuhC0kNgneDLrKkwI5qNgzwzAMbJ9jI58MPqVISCOX0bDLUPbw==
"@wordpress/primitives@*":
version "4.11.0"
resolved "https://registry.yarnpkg.com/@wordpress/primitives/-/primitives-4.11.0.tgz#7bc24c07ed11057340832791c1c21e75a5181194"
integrity sha512-CoBXbh0mOSxcZtuzL7gK3RVumFx71DXQBfd3IkbRHuuVxa+2hI4KDuFyomSsbjQDshHsfuVrKUvuT3UGt6pdpQ==
dependencies:
"@babel/runtime" "7.25.7"
"@wordpress/element" "*"
clsx "^2.1.1"
"@wordpress/scripts@~30.0.0":
version "30.0.6"
resolved "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.0.6.tgz"
@ -3727,6 +3745,11 @@ clone-deep@^4.0.1:
kind-of "^6.0.2"
shallow-clone "^3.0.0"
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"