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,4 +1,10 @@
<?php <?php
/**
* Stubs to help psalm correctly annotate problems in the plugin.
*
* @package WooCommerce
*/
if ( ! defined( 'PAYPAL_INTEGRATION_DATE' ) ) { if ( ! defined( 'PAYPAL_INTEGRATION_DATE' ) ) {
define( 'PAYPAL_INTEGRATION_DATE', '2023-06-02' ); define( 'PAYPAL_INTEGRATION_DATE', '2023-06-02' );
} }
@ -20,32 +26,58 @@ if (!defined('HOUR_IN_SECONDS')) {
if ( ! defined( 'MINUTE_IN_SECONDS' ) ) { if ( ! defined( 'MINUTE_IN_SECONDS' ) ) {
define( 'MINUTE_IN_SECONDS', 60 ); define( 'MINUTE_IN_SECONDS', 60 );
} }
if ( ! defined( 'ABSPATH' ) ) { if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', '' ); define( 'ABSPATH', '' );
} }
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' ) ) { if ( ! defined( 'PPCP_PAYPAL_BN_CODE' ) ) {
define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); 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. * 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 * While only the next instance of a recurring or cron action is unscheduled by this method, that
* all future instances of that recurring or cron action from being run. Recurring and cron actions are scheduled in * will also prevent all future instances of that recurring or cron action from being run.
* a sequence instead of all being scheduled at once. Each successive occurrence of a recurring action is scheduled * Recurring and cron actions are scheduled in a sequence instead of all being scheduled at once.
* only after the former action is run. If the next instance is never run, because it's unscheduled by this function, * Each successive occurrence of a recurring action is scheduled only after the former action is
* then the following instance will never be scheduled (or exist), which is effectively the same as being unscheduled * run. If the next instance is never run, because it's unscheduled by this function, then the
* by this method also. * 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 string $hook The hook that the job will trigger.
* @param array $args Args that would have been passed to the job. * @param array $args Args that would have been passed to the job.
* @param string $group The group the job is assigned to. * @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 * Schedule an action to run one time
@ -58,14 +90,27 @@ function as_unschedule_action($hook, $args = array(), $group = '') {}
* *
* @return int The action ID. * @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 * HTML API: WP_HTML_Tag_Processor class
*/ */
// phpcs:disable
class WP_HTML_Tag_Processor { class WP_HTML_Tag_Processor {
public function __construct( $html ) {} public function __construct( $html ) {
public function next_tag( $query = null ) {} }
public function set_attribute( $name, $value ) {}
public function get_updated_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\Orders;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentMethodTokensEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentMethodTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\CardAuthenticationResult;
use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter; use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter;
use WooCommerce\PayPalCommerce\ApiClient\Helper\FailureRegistry; 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\PartnerReferralsData;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer;
return array( return array(
'api.host' => function( ContainerInterface $container ) : string { 'api.host' => function( ContainerInterface $container ) : string {
@ -179,6 +179,22 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' ) $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 { 'api.endpoint.identity-token' => static function ( ContainerInterface $container ) : IdentityToken {
$logger = $container->get( 'woocommerce.logger.woocommerce' ); $logger = $container->get( 'woocommerce.logger.woocommerce' );
$settings = $container->get( 'wcgateway.settings' ); $settings = $container->get( 'wcgateway.settings' );
@ -845,4 +861,22 @@ return array(
$container->get( 'api.client-credentials-cache' ) $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

@ -28,7 +28,7 @@ class PayPalBearer implements Bearer {
/** /**
* The settings. * The settings.
* *
* @var ContainerInterface * @var ?ContainerInterface
*/ */
protected $settings; protected $settings;
@ -75,7 +75,7 @@ class PayPalBearer implements Bearer {
* @param string $key The key. * @param string $key The key.
* @param string $secret The secret. * @param string $secret The secret.
* @param LoggerInterface $logger The logger. * @param LoggerInterface $logger The logger.
* @param ContainerInterface $settings The settings. * @param ?ContainerInterface $settings The settings.
*/ */
public function __construct( public function __construct(
Cache $cache, Cache $cache,
@ -83,7 +83,7 @@ class PayPalBearer implements Bearer {
string $key, string $key,
string $secret, string $secret,
LoggerInterface $logger, LoggerInterface $logger,
ContainerInterface $settings ?ContainerInterface $settings
) { ) {
$this->cache = $cache; $this->cache = $cache;
@ -97,27 +97,62 @@ class PayPalBearer implements Bearer {
/** /**
* Returns a bearer token. * Returns a bearer token.
* *
* @return Token
* @throws RuntimeException When request fails. * @throws RuntimeException When request fails.
* @return Token
*/ */
public function bearer() : Token { public function bearer() : Token {
try { try {
$bearer = Token::from_json( (string) $this->cache->get( self::CACHE_KEY ) ); $bearer = Token::from_json( (string) $this->cache->get( self::CACHE_KEY ) );
return ( $bearer->is_valid() ) ? $bearer : $this->newBearer(); return ( $bearer->is_valid() ) ? $bearer : $this->newBearer();
} catch ( RuntimeException $error ) { } catch ( RuntimeException $error ) {
return $this->newBearer(); 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. * Creates a new bearer token.
* *
* @return Token
* @throws RuntimeException When request fails. * @throws RuntimeException When request fails.
* @return Token
*/ */
private function newBearer() : Token { private function newBearer() : Token {
$key = $this->settings->has( 'client_id' ) && $this->settings->get( 'client_id' ) ? $this->settings->get( 'client_id' ) : $this->key; $key = $this->get_key();
$secret = $this->settings->has( 'client_secret' ) && $this->settings->get( 'client_secret' ) ? $this->settings->get( 'client_secret' ) : $this->secret; $secret = $this->get_secret();
$url = trailingslashit( $this->host ) . 'v1/oauth2/token?grant_type=client_credentials'; $url = trailingslashit( $this->host ) . 'v1/oauth2/token?grant_type=client_credentials';
$args = array( $args = array(
@ -127,10 +162,7 @@ class PayPalBearer implements Bearer {
'Authorization' => 'Basic ' . base64_encode( $key . ':' . $secret ), 'Authorization' => 'Basic ' . base64_encode( $key . ':' . $secret ),
), ),
); );
$response = $this->request( $response = $this->request( $url, $args );
$url,
$args
);
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) { if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
$error = new RuntimeException( $error = new RuntimeException(
@ -148,6 +180,7 @@ class PayPalBearer implements Bearer {
$token = Token::from_json( $response['body'] ); $token = Token::from_json( $response['body'] );
$this->cache->set( self::CACHE_KEY, $token->as_json() ); $this->cache->set( self::CACHE_KEY, $token->as_json() );
return $token; return $token;
} }
} }

View file

@ -85,8 +85,7 @@ class PartnerReferrals {
$error = new RuntimeException( $error = new RuntimeException(
__( 'Could not create referral.', 'woocommerce-paypal-payments' ) __( 'Could not create referral.', 'woocommerce-paypal-payments' )
); );
$this->logger->log( $this->logger->warning(
'warning',
$error->getMessage(), $error->getMessage(),
array( array(
'args' => $args, 'args' => $args,
@ -95,6 +94,7 @@ class PartnerReferrals {
); );
throw $error; throw $error;
} }
$json = json_decode( $response['body'] ); $json = json_decode( $response['body'] );
$status_code = (int) wp_remote_retrieve_response_code( $response ); $status_code = (int) wp_remote_retrieve_response_code( $response );
if ( 201 !== $status_code ) { if ( 201 !== $status_code ) {
@ -102,8 +102,7 @@ class PartnerReferrals {
$json, $json,
$status_code $status_code
); );
$this->logger->log( $this->logger->warning(
'warning',
$error->getMessage(), $error->getMessage(),
array( array(
'args' => $args, 'args' => $args,
@ -122,8 +121,7 @@ class PartnerReferrals {
$error = new RuntimeException( $error = new RuntimeException(
__( 'Action URL not found.', 'woocommerce-paypal-payments' ) __( 'Action URL not found.', 'woocommerce-paypal-payments' )
); );
$this->logger->log( $this->logger->warning(
'warning',
$error->getMessage(), $error->getMessage(),
array( array(
'args' => $args, '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\ConnectBearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\LoginSeller; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\LoginSeller;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\Onboarding\Assets\OnboardingAssets; use WooCommerce\PayPalCommerce\Onboarding\Assets\OnboardingAssets;
use WooCommerce\PayPalCommerce\Onboarding\Endpoint\LoginSellerEndpoint; use WooCommerce\PayPalCommerce\Onboarding\Endpoint\LoginSellerEndpoint;
@ -66,24 +65,6 @@ return array(
? (string) $container->get( 'api.sandbox-host' ) : (string) $container->get( 'api.production-host' ); ? (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' ); $environment = $container->get( 'onboarding.environment' );
/** /**
@ -213,22 +194,6 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' ) $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' ); return new Cache( 'ppcp-paypal-signup-link' );
}, },

View file

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

View file

@ -1,7 +1,8 @@
body:has(.ppcp-r-container--onboarding) { body:has(.ppcp-r-container--onboarding) {
background-color: #fff !important; 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; display: none !important;
visibility: hidden;
} }
} }

View file

@ -20,14 +20,6 @@
margin: 0 0 16px 0; 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 { .components-base-control__field {
margin: 0 0 24px 0; margin: 0 0 24px 0;
} }

View file

@ -1,3 +1,4 @@
import { useEffect } from '@wordpress/element';
import { Icon } from '@wordpress/components'; import { Icon } from '@wordpress/components';
import { chevronDown, chevronUp } from '@wordpress/icons'; import { chevronDown, chevronUp } from '@wordpress/icons';
@ -5,11 +6,33 @@ import { useState } from 'react';
const Accordion = ( { const Accordion = ( {
title, title,
initiallyOpen = false, initiallyOpen = null,
className = '', className = '',
id = '',
children, 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 ) => { const toggleOpen = ( ev ) => {
setIsOpen( ! isOpen ); setIsOpen( ! isOpen );
@ -26,7 +49,7 @@ const Accordion = ( {
} }
return ( return (
<div className={ wrapperClasses.join( ' ' ) }> <div className={ wrapperClasses.join( ' ' ) } id={ id }>
<button <button
onClick={ toggleOpen } onClick={ toggleOpen }
className="ppcp-r-accordion--title" className="ppcp-r-accordion--title"

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,14 @@
import { __ } from '@wordpress/i18n';
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper'; import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper';
import SelectBox from '../../ReusableComponents/SelectBox'; import SelectBox from '../../ReusableComponents/SelectBox';
import { __ } from '@wordpress/i18n'; import { OnboardingHooks, BUSINESS_TYPES } from '../../../data';
import PaymentMethodIcons from '../../ReusableComponents/PaymentMethodIcons';
import { useOnboardingStepBusiness } from '../../../data';
import { BUSINESS_TYPES } from '../../../data/constants';
const BUSINESS_RADIO_GROUP_NAME = 'business'; const BUSINESS_RADIO_GROUP_NAME = 'business';
const StepBusiness = ( { const StepBusiness = ( {} ) => {
setStep, const { isCasualSeller, setIsCasualSeller } = OnboardingHooks.useBusiness();
currentStep,
stepperOrder,
setCompleted,
} ) => {
const { isCasualSeller, setIsCasualSeller } = useOnboardingStepBusiness();
const handleSellerTypeChange = ( value ) => { const handleSellerTypeChange = ( value ) => {
setIsCasualSeller( BUSINESS_TYPES.CASUAL_SELLER === value ); setIsCasualSeller( BUSINESS_TYPES.CASUAL_SELLER === value );
@ -55,8 +49,7 @@ const StepBusiness = ( {
currentValue={ getCurrentValue() } currentValue={ getCurrentValue() }
checked={ isCasualSeller === false } checked={ isCasualSeller === false }
type="radio" type="radio"
> ></SelectBox>
</SelectBox>
<SelectBox <SelectBox
title={ __( title={ __(
'Personal Account', 'Personal Account',
@ -72,8 +65,7 @@ const StepBusiness = ( {
currentValue={ getCurrentValue() } currentValue={ getCurrentValue() }
checked={ isCasualSeller === true } checked={ isCasualSeller === true }
type="radio" type="radio"
> ></SelectBox>
</SelectBox>
</SelectBoxWrapper> </SelectBoxWrapper>
</div> </div>
</div> </div>

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import { useOnboardingStep } from '../../data'; import { OnboardingHooks } from '../../data';
import Onboarding from './Onboarding/Onboarding'; import Onboarding from './Onboarding/Onboarding';
import SettingsScreen from './SettingsScreen'; import SettingsScreen from './SettingsScreen';
const Settings = () => { const Settings = () => {
const onboardingProgress = useOnboardingStep(); const onboardingProgress = OnboardingHooks.useSteps();
if ( ! onboardingProgress.isReady ) { if ( ! onboardingProgress.isReady ) {
// TODO: Use better loading state indicator. // 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 = { export const BUSINESS_TYPES = {
CASUAL_SELLER: 'casual_seller', CASUAL_SELLER: 'casual_seller',
BUSINESS: 'business', 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 { addDebugTools } from './debug';
import { initStore } from './store'; import * as Onboarding from './onboarding';
import * as Common from './common';
initStore(); Onboarding.initStore();
Common.initStore();
export const WC_PAYPAL_STORE_NAME = STORE_NAME; export const OnboardingHooks = Onboarding.hooks;
export * from './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. // Transient data.
SET_ONBOARDING_IS_READY: 'SET_ONBOARDING_IS_READY', SET_TRANSIENT: 'ONBOARDING:SET_TRANSIENT',
SET_IS_SAVING_ONBOARDING: 'SET_IS_SAVING_ONBOARDING',
SET_MANUAL_CONNECTION_BUSY: 'SET_MANUAL_CONNECTION_BUSY',
// Persistent data. // Persistent data.
SET_ONBOARDING_COMPLETED: 'SET_ONBOARDING_COMPLETED', SET_PERSISTENT: 'ONBOARDING:SET_PERSISTENT',
SET_ONBOARDING_DETAILS: 'SET_ONBOARDING_DETAILS', RESET: 'ONBOARDING:RESET',
SET_ONBOARDING_STEP: 'SET_ONBOARDING_STEP', HYDRATE: 'ONBOARDING:HYDRATE',
SET_SANDBOX_MODE: 'SET_SANDBOX_MODE',
SET_MANUAL_CONNECTION_MODE: 'SET_MANUAL_CONNECTION_MODE', // Controls - always start with "DO_".
SET_CLIENT_ID: 'SET_CLIENT_ID', DO_PERSIST_DATA: 'ONBOARDING:DO_PERSIST_DATA',
SET_CLIENT_SECRET: 'SET_CLIENT_SECRET',
SET_IS_CASUAL_SELLER: 'SET_IS_CASUAL_SELLER',
SET_PRODUCTS: 'SET_PRODUCTS',
}; };

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 { select } from '@wordpress/data';
import { apiFetch } from '@wordpress/data-controls';
import ACTION_TYPES from './action-types'; 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. * Special. Resets all values in the onboarding store to initial defaults.
* *
* @return {{type: string}} The action. * @return {Action} The action.
*/ */
export const resetOnboarding = () => { export const reset = () => ( { type: ACTION_TYPES.RESET } );
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,
};
};
/** /**
* Persistent. Set the full onboarding details, usually during app initialization. * Persistent. Set the full onboarding details, usually during app initialization.
* *
* @param {{data: {}, flags?: {}}} payload * @param {{data: {}, flags?: {}}} payload
* @return {{type: string, payload}} The action. * @return {Action} The action.
*/ */
export const setOnboardingDetails = ( payload ) => { export const hydrate = ( payload ) => ( {
return { type: ACTION_TYPES.HYDRATE,
type: ACTION_TYPES.SET_ONBOARDING_DETAILS,
payload, 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. * Persistent.Set the "onboarding completed" flag which shows or hides the wizard.
* *
* @param {boolean} completed * @param {boolean} completed
* @return {{type: string, payload}} The action. * @return {Action} The action.
*/ */
export const setCompleted = ( completed ) => { export const setCompleted = ( completed ) => ( {
return { type: ACTION_TYPES.SET_PERSISTENT,
type: ACTION_TYPES.SET_ONBOARDING_COMPLETED, payload: { completed },
completed, } );
};
};
/** /**
* Persistent. Sets the onboarding wizard to a new step. * Persistent. Sets the onboarding wizard to a new step.
* *
* @param {number} step * @param {number} step
* @return {{type: string, step}} An action. * @return {Action} The action.
*/ */
export const setOnboardingStep = ( step ) => { export const setStep = ( step ) => ( {
return { type: ACTION_TYPES.SET_PERSISTENT,
type: ACTION_TYPES.SET_ONBOARDING_STEP, payload: { 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,
};
};
/** /**
* Persistent. Sets the "isCasualSeller" value. * Persistent. Sets the "isCasualSeller" value.
* *
* @param {boolean} isCasualSeller * @param {boolean} isCasualSeller
* @return {{type: string, isCasualSeller}} The action. * @return {Action} The action.
*/ */
export const setIsCasualSeller = ( isCasualSeller ) => { export const setIsCasualSeller = ( isCasualSeller ) => ( {
return { type: ACTION_TYPES.SET_PERSISTENT,
type: ACTION_TYPES.SET_IS_CASUAL_SELLER, payload: { isCasualSeller },
isCasualSeller, } );
};
};
/** /**
* Persistent. Sets the "products" array. * Persistent. Sets the "products" array.
* *
* @param {string[]} products * @param {string[]} products
* @return {{type: string, products}} The action. * @return {Action} The action.
*/ */
export const setProducts = ( products ) => { export const setProducts = ( products ) => ( {
return { type: ACTION_TYPES.SET_PERSISTENT,
type: ACTION_TYPES.SET_PRODUCTS, payload: { products },
products, } );
};
};
/** /**
* Attempts to establish a connection using client ID and secret via the server-side * Side effect. Triggers the persistence of onboarding data to the server.
* connection endpoint.
* *
* @return {Object} The server response object * @return {Action} The action.
*/ */
export function* connectViaIdAndSecret() { export const persist = function* () {
let result = null; const data = yield select( STORE_NAME ).persistentData();
try { yield { type: ACTION_TYPES.DO_PERSIST_DATA, data };
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 { 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 = () => { import { PRODUCT_TYPES } from '../constants';
const { import { STORE_NAME } from './constants';
persist,
setOnboardingStep,
setCompleted,
setSandboxMode,
setManualConnectionMode,
setClientId,
setClientSecret,
setIsCasualSeller,
setProducts,
} = useDispatch( STORE_NAME );
// Transient accessors. const useTransient = ( key ) =>
const isSaving = useSelect( ( select ) => { useSelect(
return select( STORE_NAME ).getTransientData().isSaving; ( select ) => select( STORE_NAME ).transientData()?.[ key ],
}, [] ); [ key ]
);
const isReady = useSelect( ( select ) => { const usePersistent = ( key ) =>
return select( STORE_NAME ).getTransientData().isReady; useSelect(
} ); ( select ) => select( STORE_NAME ).persistentData()?.[ key ],
[ key ]
);
const isManualConnectionBusy = useSelect( ( select ) => { const useHooks = () => {
return select( STORE_NAME ).getTransientData().isManualConnectionBusy; const { persist, setStep, setCompleted, setIsCasualSeller, setProducts } =
}, [] ); useDispatch( STORE_NAME );
// Read-only flags. // Read-only flags.
const flags = useSelect( ( select ) => { const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] );
return select( STORE_NAME ).getFlags();
} ); // Transient accessors.
const isReady = useTransient( 'isReady' );
// Persistent accessors. // Persistent accessors.
const step = useSelect( ( select ) => { const step = usePersistent( 'step' );
return select( STORE_NAME ).getPersistentData().step || 0; const completed = usePersistent( 'completed' );
} ); const isCasualSeller = usePersistent( 'isCasualSeller' );
const products = usePersistent( 'products' );
const completed = useSelect( ( select ) => { const savePersistent = async ( setter, value ) => {
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 ) => {
setter( value ); setter( value );
await persist(); await persist();
}; };
return { 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, 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 = () => { export const useBusiness = () => {
const { const { isCasualSeller, setIsCasualSeller } = useHooks();
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();
return { isCasualSeller, setIsCasualSeller }; return { isCasualSeller, setIsCasualSeller };
}; };
export const useOnboardingStepProducts = () => { export const useProducts = () => {
const { products, toggleProduct } = useOnboardingDetails(); const { products, setProducts } = useHooks();
return { products, toggleProduct }; return { products, setProducts };
}; };
export const useOnboardingStep = () => { export const useSteps = () => {
const { isReady, step, setStep, completed, setCompleted, flags } = const { flags, isReady, step, setStep, completed, setCompleted } =
useOnboardingDetails(); useHooks();
return { isReady, step, setStep, completed, setCompleted, flags }; return { flags, isReady, step, setStep, completed, setCompleted };
};
export const useManualConnect = () => {
const { connectViaIdAndSecret } = useDispatch( STORE_NAME );
return {
connectManual: connectViaIdAndSecret,
};
}; };

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 reducer from './reducer';
import * as selectors from './selectors'; import * as selectors from './selectors';
import * as actions from './actions'; 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'; import ACTION_TYPES from './action-types';
const defaultState = { // Store structure.
isReady: false,
isSaving: false,
isManualConnectionBusy: false,
// Data persisted to the server. const defaultTransient = {
data: { isReady: false,
completed: false,
step: 0,
useSandbox: false,
useManualConnection: false,
clientId: '',
clientSecret: '',
isCasualSeller: null, // null value will uncheck both options in the UI.
products: [],
},
// Read only values, provided by the server. // Read only values, provided by the server.
flags: { flags: {
@ -25,83 +23,40 @@ const defaultState = {
}, },
}; };
export const onboardingReducer = ( const defaultPersistent = {
state = defaultState, completed: false,
{ type, ...action } step: 0,
) => { isCasualSeller: null, // null value will uncheck both options in the UI.
const setTransient = ( changes ) => { products: [],
const { data, ...transientChanges } = changes;
return { ...state, ...transientChanges };
}; };
const setPersistent = ( changes ) => { // Reducer logic.
const validChanges = Object.keys( changes ).reduce( ( acc, key ) => {
if ( key in defaultState.data ) {
acc[ key ] = changes[ key ];
}
return acc;
}, {} );
return { const [ setTransient, setPersistent ] = createSetters(
...state, defaultTransient,
data: { ...state.data, ...validChanges }, defaultPersistent
}; );
};
switch ( type ) { const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
// Reset store to initial state. [ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) =>
case ACTION_TYPES.RESET_ONBOARDING: setTransient( state, payload ),
return setPersistent( defaultState.data );
// Transient data. [ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) =>
case ACTION_TYPES.SET_ONBOARDING_IS_READY: setPersistent( state, payload ),
return setTransient( { isReady: action.isReady } );
case ACTION_TYPES.SET_IS_SAVING_ONBOARDING: [ ACTION_TYPES.RESET ]: ( state ) =>
return setTransient( { isSaving: action.isSaving } ); setPersistent( state, defaultPersistent ),
case ACTION_TYPES.SET_MANUAL_CONNECTION_BUSY: [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
return setTransient( { isManualConnectionBusy: action.isBusy } ); const newState = setPersistent( state, payload.data );
// Persistent data. // Flags are not updated by `setPersistent()`.
case ACTION_TYPES.SET_ONBOARDING_DETAILS: if ( payload.flags ) {
const newState = setPersistent( action.payload.data ); newState.flags = { ...newState.flags, ...payload.flags };
if ( action.payload.flags ) {
newState.flags = { ...newState.flags, ...action.payload.flags };
} }
return newState; 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;
}
};
export default onboardingReducer; export default onboardingReducer;

View file

@ -1,19 +1,29 @@
/**
* 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 { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { apiFetch } from '@wordpress/data-controls'; import { apiFetch } from '@wordpress/data-controls';
import { NAMESPACE } from '../constants';
import { setIsReady, setOnboardingDetails } from './actions';
import { STORE_NAME, REST_HYDRATE_PATH } from './constants';
export const resolvers = {
/** /**
* Retrieve settings from the site's REST API. * Retrieve settings from the site's REST API.
*/ */
export function* getPersistentData() { *persistentData() {
const path = `${ NAMESPACE }/onboarding`;
try { try {
const result = yield apiFetch( { path } ); const result = yield apiFetch( { path: REST_HYDRATE_PATH } );
yield setOnboardingDetails( result );
yield setIsReady( true ); yield dispatch( STORE_NAME ).hydrate( result );
yield dispatch( STORE_NAME ).setIsReady( true );
} catch ( e ) { } catch ( e ) {
yield dispatch( 'core/notices' ).createErrorNotice( yield dispatch( 'core/notices' ).createErrorNotice(
__( __(
@ -22,4 +32,5 @@ export function* getPersistentData() {
) )
); );
} }
} },
};

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 EMPTY_OBJ = Object.freeze( {} );
const getOnboardingState = ( state ) => { const getState = ( state ) => state || EMPTY_OBJ;
if ( ! state ) {
return EMPTY_OBJ;
}
return state.onboarding || EMPTY_OBJ; export const persistentData = ( state ) => {
return getState( state ).data || EMPTY_OBJ;
}; };
export const getPersistentData = ( state ) => { export const transientData = ( state ) => {
return getOnboardingState( state ).data || EMPTY_OBJ; const { data, flags, ...transientState } = getState( state );
};
export const getTransientData = ( state ) => {
const { data, flags, ...transientState } = getOnboardingState( state );
return transientState || EMPTY_OBJ; return transientState || EMPTY_OBJ;
}; };
export const getFlags = ( state ) => { export const flags = ( state ) => {
return getOnboardingState( state ).flags || EMPTY_OBJ; 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; namespace WooCommerce\PayPalCommerce\Settings;
use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile; 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( return array(
'settings.url' => static function ( ContainerInterface $container ) : string { 'settings.url' => static function ( ContainerInterface $container ) : string {
@ -44,9 +49,15 @@ return array(
$can_use_card_payments $can_use_card_payments
); );
}, },
'settings.data.common' => static function ( ContainerInterface $container ) : CommonSettings {
return new CommonSettings();
},
'settings.rest.onboarding' => static function ( ContainerInterface $container ) : OnboardingRestEndpoint { 'settings.rest.onboarding' => static function ( ContainerInterface $container ) : OnboardingRestEndpoint {
return new OnboardingRestEndpoint( $container->get( 'settings.data.onboarding' ) ); 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 { 'settings.rest.connect_manual' => static function ( ContainerInterface $container ) : ConnectManualRestEndpoint {
return new ConnectManualRestEndpoint( return new ConnectManualRestEndpoint(
$container->get( 'api.paypal-host-production' ), $container->get( 'api.paypal-host-production' ),
@ -54,6 +65,11 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' ) $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 { 'settings.casual-selling.supported-countries' => static function ( ContainerInterface $container ) : array {
return array( return array(
'AR', 'AR',
@ -110,6 +126,35 @@ return array(
return in_array( $country, $eligible_countries, true ); 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 { 'settings.switch-ui.endpoint' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint {
return new SwitchSettingsUiEndpoint( return new SwitchSettingsUiEndpoint(
$container->get( 'woocommerce.logger.woocommerce' ), $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

@ -66,10 +66,6 @@ class OnboardingProfile extends AbstractDataModel {
return array( return array(
'completed' => false, 'completed' => false,
'step' => 0, 'step' => 0,
'use_sandbox' => false,
'use_manual_connection' => false,
'client_id' => '',
'client_secret' => '',
'is_casual_seller' => null, 'is_casual_seller' => null,
'products' => array(), 'products' => array(),
); );
@ -113,78 +109,6 @@ class OnboardingProfile extends AbstractDataModel {
$this->data['step'] = $step; $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. * 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

@ -126,47 +126,38 @@ class ConnectManualRestEndpoint extends RestEndpoint {
$use_sandbox = (bool) ( $data['use_sandbox'] ?? false ); $use_sandbox = (bool) ( $data['use_sandbox'] ?? false );
if ( empty( $client_id ) || empty( $client_secret ) ) { if ( empty( $client_id ) || empty( $client_secret ) ) {
return rest_ensure_response( return $this->return_error( 'No client ID or secret provided.' );
array(
'success' => false,
'message' => 'No client ID or secret provided.',
)
);
} }
try { try {
$payee = $this->request_payee( $client_id, $client_secret, $use_sandbox ); $payee = $this->request_payee( $client_id, $client_secret, $use_sandbox );
} catch ( Exception $exception ) { } catch ( Exception $exception ) {
return rest_ensure_response( return $this->return_error( $exception->getMessage() );
array(
'success' => false,
'message' => $exception->getMessage(),
)
);
} }
$result = array( return $this->return_success(
array(
'merchantId' => $payee->merchant_id, 'merchantId' => $payee->merchant_id,
'email' => $payee->email_address, 'email' => $payee->email_address,
'success' => true, 'success' => true,
)
); );
return rest_ensure_response( $result );
} }
/** /**
* Retrieves the payee object with the merchant data * Retrieves the payee object with the merchant data
* by creating a minimal PayPal order. * 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. * @throws Exception When failed to retrieve payee.
* *
* phpcs:disable Squiz.Commenting * phpcs:disable Squiz.Commenting
* phpcs:disable Generic.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( private function request_payee(
string $client_id, string $client_id,
@ -176,24 +167,13 @@ class ConnectManualRestEndpoint extends RestEndpoint {
$host = $use_sandbox ? $this->sandbox_host : $this->live_host; $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( $bearer = new PayPalBearer(
new InMemoryCache(), new InMemoryCache(),
$host, $host,
$client_id, $client_id,
$client_secret, $client_secret,
$this->logger, $this->logger,
$empty_settings null
); );
$orders = new Orders( $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

@ -49,22 +49,6 @@ class OnboardingRestEndpoint extends RestEndpoint {
'js_name' => 'step', 'js_name' => 'step',
'sanitize' => 'to_number', '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', 'js_name' => 'isCasualSeller',
'sanitize' => 'to_boolean', 'sanitize' => 'to_boolean',
@ -147,9 +131,9 @@ class OnboardingRestEndpoint extends RestEndpoint {
$this->flag_map $this->flag_map
); );
return rest_ensure_response( return $this->return_success(
$js_data,
array( array(
'data' => $js_data,
'flags' => $js_flags, 'flags' => $js_flags,
) )
); );

View file

@ -10,14 +10,12 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Endpoint; namespace WooCommerce\PayPalCommerce\Settings\Endpoint;
use WC_REST_Controller; use WC_REST_Controller;
use WP_REST_Response;
/** /**
* Base class for REST controllers in the settings module. * 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. * Endpoint namespace.
* *
@ -34,6 +32,54 @@ class RestEndpoint extends WC_REST_Controller {
return current_user_can( 'manage_woocommerce' ); 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. * Sanitizes parameters based on a field mapping.
* *

View file

@ -15,6 +15,8 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData;
/** /**
* Class SwitchSettingsUiEndpoint * Class SwitchSettingsUiEndpoint
*
* Note: This is an ajax handler, not a REST endpoint
*/ */
class SwitchSettingsUiEndpoint { 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; namespace WooCommerce\PayPalCommerce\Settings;
use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
@ -89,7 +88,13 @@ class SettingsModule implements ServiceModule, ExecutableModule {
$endpoint = $container->get( 'settings.switch-ui.endpoint' ); $endpoint = $container->get( 'settings.switch-ui.endpoint' );
assert( $endpoint instanceof SwitchSettingsUiEndpoint ); 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; return true;
} }
@ -170,13 +175,17 @@ class SettingsModule implements ServiceModule, ExecutableModule {
add_action( add_action(
'rest_api_init', 'rest_api_init',
static function () use ( $container ) : void { static function () use ( $container ) : void {
$onboarding_endpoint = $container->get( 'settings.rest.onboarding' ); $endpoints = array(
assert( $onboarding_endpoint instanceof OnboardingRestEndpoint ); $container->get( 'settings.rest.onboarding' ),
$onboarding_endpoint->register_routes(); $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' ); foreach ( $endpoints as $endpoint ) {
assert( $connect_manual_endpoint instanceof ConnectManualRestEndpoint ); assert( $endpoint instanceof RestEndpoint );
$connect_manual_endpoint->register_routes(); $endpoint->register_routes();
}
} }
); );

View file

@ -2671,9 +2671,9 @@
mime "^3.0.0" mime "^3.0.0"
web-vitals "^4.2.1" web-vitals "^4.2.1"
"@wordpress/element@^6.1.0": "@wordpress/element@*", "@wordpress/element@^6.1.0":
version "6.11.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== integrity sha512-UvHFYkT+EEaXEyEfw+iqLHRO9OwBjjsUydEMHcqntzkNcsYeAbmaL9V8R9ikXHLe6ftdbkwoXgF85xVPhVsL+Q==
dependencies: dependencies:
"@babel/runtime" "7.25.7" "@babel/runtime" "7.25.7"
@ -2715,6 +2715,15 @@
globals "^13.12.0" globals "^13.12.0"
requireindex "^1.2.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@*": "@wordpress/jest-console@*":
version "8.11.0" version "8.11.0"
resolved "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.11.0.tgz" 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" resolved "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.11.0.tgz"
integrity sha512-Aoc8+xWOyiXekodjaEjS44z85XK877LzHZqsQuhC0kNgneDLrKkwI5qNgzwzAMbJ9jI58MPqVISCOX0bDLUPbw== 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": "@wordpress/scripts@~30.0.0":
version "30.0.6" version "30.0.6"
resolved "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.0.6.tgz" 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" kind-of "^6.0.2"
shallow-clone "^3.0.0" 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: co@^4.6.0:
version "4.6.0" version "4.6.0"
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz" resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"