diff --git a/.psalm/stubs.php b/.psalm/stubs.php index 59be5215c..56ba72451 100644 --- a/.psalm/stubs.php +++ b/.psalm/stubs.php @@ -1,71 +1,116 @@ function( ContainerInterface $container ) : string { @@ -179,6 +179,22 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, + 'api.endpoint.partner-referrals-sandbox' => static function ( ContainerInterface $container ) : PartnerReferrals { + + return new PartnerReferrals( + CONNECT_WOO_SANDBOX_URL, + new ConnectBearer(), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + 'api.endpoint.partner-referrals-production' => static function ( ContainerInterface $container ) : PartnerReferrals { + + return new PartnerReferrals( + CONNECT_WOO_URL, + new ConnectBearer(), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, 'api.endpoint.identity-token' => static function ( ContainerInterface $container ) : IdentityToken { $logger = $container->get( 'woocommerce.logger.woocommerce' ); $settings = $container->get( 'wcgateway.settings' ); @@ -845,4 +861,22 @@ return array( $container->get( 'api.client-credentials-cache' ) ); }, + 'api.paypal-host-production' => static function( ContainerInterface $container ) : string { + return PAYPAL_API_URL; + }, + 'api.paypal-host-sandbox' => static function( ContainerInterface $container ) : string { + return PAYPAL_SANDBOX_API_URL; + }, + 'api.paypal-website-url-production' => static function( ContainerInterface $container ) : string { + return PAYPAL_URL; + }, + 'api.paypal-website-url-sandbox' => static function( ContainerInterface $container ) : string { + return PAYPAL_SANDBOX_URL; + }, + 'api.partner_merchant_id-production' => static function( ContainerInterface $container ) : string { + return CONNECT_WOO_MERCHANT_ID; + }, + 'api.partner_merchant_id-sandbox' => static function( ContainerInterface $container ) : string { + return CONNECT_WOO_SANDBOX_MERCHANT_ID; + }, ); diff --git a/modules/ppcp-api-client/src/Authentication/PayPalBearer.php b/modules/ppcp-api-client/src/Authentication/PayPalBearer.php index f5cab579b..7f176c218 100644 --- a/modules/ppcp-api-client/src/Authentication/PayPalBearer.php +++ b/modules/ppcp-api-client/src/Authentication/PayPalBearer.php @@ -5,7 +5,7 @@ * @package WooCommerce\PayPalCommerce\ApiClient\Authentication */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\ApiClient\Authentication; @@ -28,7 +28,7 @@ class PayPalBearer implements Bearer { /** * The settings. * - * @var ContainerInterface + * @var ?ContainerInterface */ protected $settings; @@ -70,12 +70,12 @@ class PayPalBearer implements Bearer { /** * PayPalBearer constructor. * - * @param Cache $cache The cache. - * @param string $host The host. - * @param string $key The key. - * @param string $secret The secret. - * @param LoggerInterface $logger The logger. - * @param ContainerInterface $settings The settings. + * @param Cache $cache The cache. + * @param string $host The host. + * @param string $key The key. + * @param string $secret The secret. + * @param LoggerInterface $logger The logger. + * @param ?ContainerInterface $settings The settings. */ public function __construct( Cache $cache, @@ -83,7 +83,7 @@ class PayPalBearer implements Bearer { string $key, string $secret, LoggerInterface $logger, - ContainerInterface $settings + ?ContainerInterface $settings ) { $this->cache = $cache; @@ -97,27 +97,62 @@ class PayPalBearer implements Bearer { /** * Returns a bearer token. * - * @return Token * @throws RuntimeException When request fails. + * @return Token */ - public function bearer(): Token { + public function bearer() : Token { try { $bearer = Token::from_json( (string) $this->cache->get( self::CACHE_KEY ) ); + return ( $bearer->is_valid() ) ? $bearer : $this->newBearer(); } catch ( RuntimeException $error ) { return $this->newBearer(); } } + /** + * Retrieves the client key for authentication. + * + * @return string The client ID from settings, or the key defined via constructor. + */ + private function get_key() : string { + if ( + $this->settings + && $this->settings->has( 'client_id' ) + && $this->settings->get( 'client_id' ) + ) { + return $this->settings->get( 'client_id' ); + } + + return $this->key; + } + + /** + * Retrieves the client secret for authentication. + * + * @return string The client secret from settings, or the value defined via constructor. + */ + private function get_secret() : string { + if ( + $this->settings + && $this->settings->has( 'client_secret' ) + && $this->settings->get( 'client_secret' ) + ) { + return $this->settings->get( 'client_secret' ); + } + + return $this->secret; + } + /** * Creates a new bearer token. * - * @return Token * @throws RuntimeException When request fails. + * @return Token */ - private function newBearer(): Token { - $key = $this->settings->has( 'client_id' ) && $this->settings->get( 'client_id' ) ? $this->settings->get( 'client_id' ) : $this->key; - $secret = $this->settings->has( 'client_secret' ) && $this->settings->get( 'client_secret' ) ? $this->settings->get( 'client_secret' ) : $this->secret; + private function newBearer() : Token { + $key = $this->get_key(); + $secret = $this->get_secret(); $url = trailingslashit( $this->host ) . 'v1/oauth2/token?grant_type=client_credentials'; $args = array( @@ -127,10 +162,7 @@ class PayPalBearer implements Bearer { 'Authorization' => 'Basic ' . base64_encode( $key . ':' . $secret ), ), ); - $response = $this->request( - $url, - $args - ); + $response = $this->request( $url, $args ); if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) { $error = new RuntimeException( @@ -148,6 +180,7 @@ class PayPalBearer implements Bearer { $token = Token::from_json( $response['body'] ); $this->cache->set( self::CACHE_KEY, $token->as_json() ); + return $token; } } diff --git a/modules/ppcp-api-client/src/Endpoint/PartnerReferrals.php b/modules/ppcp-api-client/src/Endpoint/PartnerReferrals.php index 0f188f025..c7f5ec131 100644 --- a/modules/ppcp-api-client/src/Endpoint/PartnerReferrals.php +++ b/modules/ppcp-api-client/src/Endpoint/PartnerReferrals.php @@ -85,8 +85,7 @@ class PartnerReferrals { $error = new RuntimeException( __( 'Could not create referral.', 'woocommerce-paypal-payments' ) ); - $this->logger->log( - 'warning', + $this->logger->warning( $error->getMessage(), array( 'args' => $args, @@ -95,6 +94,7 @@ class PartnerReferrals { ); throw $error; } + $json = json_decode( $response['body'] ); $status_code = (int) wp_remote_retrieve_response_code( $response ); if ( 201 !== $status_code ) { @@ -102,8 +102,7 @@ class PartnerReferrals { $json, $status_code ); - $this->logger->log( - 'warning', + $this->logger->warning( $error->getMessage(), array( 'args' => $args, @@ -122,8 +121,7 @@ class PartnerReferrals { $error = new RuntimeException( __( 'Action URL not found.', 'woocommerce-paypal-payments' ) ); - $this->logger->log( - 'warning', + $this->logger->warning( $error->getMessage(), array( 'args' => $args, diff --git a/modules/ppcp-onboarding/services.php b/modules/ppcp-onboarding/services.php index 369e82824..56a49be8e 100644 --- a/modules/ppcp-onboarding/services.php +++ b/modules/ppcp-onboarding/services.php @@ -15,7 +15,6 @@ use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\LoginSeller; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\Onboarding\Assets\OnboardingAssets; use WooCommerce\PayPalCommerce\Onboarding\Endpoint\LoginSellerEndpoint; @@ -25,7 +24,7 @@ use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingRenderer; use WooCommerce\PayPalCommerce\Onboarding\OnboardingRESTController; return array( - 'api.sandbox-host' => static function ( ContainerInterface $container ): string { + 'api.sandbox-host' => static function ( ContainerInterface $container ): string { $state = $container->get( 'onboarding.state' ); @@ -39,7 +38,7 @@ return array( } return CONNECT_WOO_SANDBOX_URL; }, - 'api.production-host' => static function ( ContainerInterface $container ): string { + 'api.production-host' => static function ( ContainerInterface $container ): string { $state = $container->get( 'onboarding.state' ); @@ -54,7 +53,7 @@ return array( } return CONNECT_WOO_URL; }, - 'api.host' => static function ( ContainerInterface $container ): string { + 'api.host' => static function ( ContainerInterface $container ): string { $environment = $container->get( 'onboarding.environment' ); /** @@ -66,25 +65,7 @@ return array( ? (string) $container->get( 'api.sandbox-host' ) : (string) $container->get( 'api.production-host' ); }, - 'api.paypal-host-production' => static function( ContainerInterface $container ) : string { - return PAYPAL_API_URL; - }, - 'api.paypal-host-sandbox' => static function( ContainerInterface $container ) : string { - return PAYPAL_SANDBOX_API_URL; - }, - 'api.paypal-website-url-production' => static function( ContainerInterface $container ) : string { - return PAYPAL_URL; - }, - 'api.paypal-website-url-sandbox' => static function( ContainerInterface $container ) : string { - return PAYPAL_SANDBOX_URL; - }, - 'api.partner_merchant_id-production' => static function( ContainerInterface $container ) : string { - return CONNECT_WOO_MERCHANT_ID; - }, - 'api.partner_merchant_id-sandbox' => static function( ContainerInterface $container ) : string { - return CONNECT_WOO_SANDBOX_MERCHANT_ID; - }, - 'api.paypal-host' => function( ContainerInterface $container ) : string { + 'api.paypal-host' => function( ContainerInterface $container ) : string { $environment = $container->get( 'onboarding.environment' ); /** * The current environment. @@ -97,7 +78,7 @@ return array( return $container->get( 'api.paypal-host-production' ); }, - 'api.paypal-website-url' => function( ContainerInterface $container ) : string { + 'api.paypal-website-url' => function( ContainerInterface $container ) : string { $environment = $container->get( 'onboarding.environment' ); assert( $environment instanceof Environment ); if ( $environment->current_environment_is( Environment::SANDBOX ) ) { @@ -107,7 +88,7 @@ return array( }, - 'api.bearer' => static function ( ContainerInterface $container ): Bearer { + 'api.bearer' => static function ( ContainerInterface $container ): Bearer { $state = $container->get( 'onboarding.state' ); @@ -134,16 +115,16 @@ return array( $settings ); }, - 'onboarding.state' => function( ContainerInterface $container ) : State { + 'onboarding.state' => function( ContainerInterface $container ) : State { $settings = $container->get( 'wcgateway.settings' ); return new State( $settings ); }, - 'onboarding.environment' => function( ContainerInterface $container ) : Environment { + 'onboarding.environment' => function( ContainerInterface $container ) : Environment { $settings = $container->get( 'wcgateway.settings' ); return new Environment( $settings ); }, - 'onboarding.assets' => function( ContainerInterface $container ) : OnboardingAssets { + 'onboarding.assets' => function( ContainerInterface $container ) : OnboardingAssets { $state = $container->get( 'onboarding.state' ); $login_seller_endpoint = $container->get( 'onboarding.endpoint.login-seller' ); return new OnboardingAssets( @@ -156,14 +137,14 @@ return array( ); }, - 'onboarding.url' => static function ( ContainerInterface $container ): string { + 'onboarding.url' => static function ( ContainerInterface $container ): string { return plugins_url( '/modules/ppcp-onboarding/', dirname( realpath( __FILE__ ), 3 ) . '/woocommerce-paypal-payments.php' ); }, - 'api.endpoint.login-seller-production' => static function ( ContainerInterface $container ) : LoginSeller { + 'api.endpoint.login-seller-production' => static function ( ContainerInterface $container ) : LoginSeller { $logger = $container->get( 'woocommerce.logger.woocommerce' ); return new LoginSeller( @@ -173,7 +154,7 @@ return array( ); }, - 'api.endpoint.login-seller-sandbox' => static function ( ContainerInterface $container ) : LoginSeller { + 'api.endpoint.login-seller-sandbox' => static function ( ContainerInterface $container ) : LoginSeller { $logger = $container->get( 'woocommerce.logger.woocommerce' ); return new LoginSeller( @@ -183,7 +164,7 @@ return array( ); }, - 'onboarding.endpoint.login-seller' => static function ( ContainerInterface $container ) : LoginSellerEndpoint { + 'onboarding.endpoint.login-seller' => static function ( ContainerInterface $container ) : LoginSellerEndpoint { $request_data = $container->get( 'button.request-data' ); $login_seller_production = $container->get( 'api.endpoint.login-seller-production' ); @@ -203,7 +184,7 @@ return array( new Cache( 'ppcp-client-credentials-cache' ) ); }, - 'onboarding.endpoint.pui' => static function( ContainerInterface $container ) : UpdateSignupLinksEndpoint { + 'onboarding.endpoint.pui' => static function( ContainerInterface $container ) : UpdateSignupLinksEndpoint { return new UpdateSignupLinksEndpoint( $container->get( 'wcgateway.settings' ), $container->get( 'button.request-data' ), @@ -213,26 +194,10 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, - 'api.endpoint.partner-referrals-sandbox' => static function ( ContainerInterface $container ) : PartnerReferrals { - - return new PartnerReferrals( - CONNECT_WOO_SANDBOX_URL, - new ConnectBearer(), - $container->get( 'woocommerce.logger.woocommerce' ) - ); - }, - 'api.endpoint.partner-referrals-production' => static function ( ContainerInterface $container ) : PartnerReferrals { - - return new PartnerReferrals( - CONNECT_WOO_URL, - new ConnectBearer(), - $container->get( 'woocommerce.logger.woocommerce' ) - ); - }, - 'onboarding.signup-link-cache' => static function( ContainerInterface $container ): Cache { + 'onboarding.signup-link-cache' => static function( ContainerInterface $container ): Cache { return new Cache( 'ppcp-paypal-signup-link' ); }, - 'onboarding.signup-link-ids' => static function ( ContainerInterface $container ): array { + 'onboarding.signup-link-ids' => static function ( ContainerInterface $container ): array { return array( 'production-ppcp', 'production-express_checkout', @@ -240,12 +205,12 @@ return array( 'sandbox-express_checkout', ); }, - 'onboarding.render-send-only-notice' => static function( ContainerInterface $container ) { + 'onboarding.render-send-only-notice' => static function( ContainerInterface $container ) { return new OnboardingSendOnlyNoticeRenderer( $container->get( 'wcgateway.send-only-message' ) ); }, - 'onboarding.render' => static function ( ContainerInterface $container ) : OnboardingRenderer { + 'onboarding.render' => static function ( ContainerInterface $container ) : OnboardingRenderer { $partner_referrals = $container->get( 'api.endpoint.partner-referrals-production' ); $partner_referrals_sandbox = $container->get( 'api.endpoint.partner-referrals-sandbox' ); $partner_referrals_data = $container->get( 'api.repository.partner-referrals-data' ); @@ -261,14 +226,14 @@ return array( $logger ); }, - 'onboarding.render-options' => static function ( ContainerInterface $container ) : OnboardingOptionsRenderer { + 'onboarding.render-options' => static function ( ContainerInterface $container ) : OnboardingOptionsRenderer { return new OnboardingOptionsRenderer( $container->get( 'onboarding.url' ), $container->get( 'api.shop.country' ), $container->get( 'wcgateway.settings' ) ); }, - 'onboarding.rest' => static function( $container ) : OnboardingRESTController { + 'onboarding.rest' => static function( $container ) : OnboardingRESTController { return new OnboardingRESTController( $container ); }, ); diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_badge-box.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_badge-box.scss index d65d1f184..427b4b1fb 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_badge-box.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_badge-box.scss @@ -18,7 +18,7 @@ } } - &-image-badge { + .ppcp-r-badge-box__title-text:not(:empty) + .ppcp-r-badge-box__title-image-badge { margin-left: 7px; img { diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_button.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_button.scss index 027016760..9815633e3 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_button.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_button.scss @@ -8,16 +8,16 @@ button.components-button, a.components-button { color: $color-white; } - border-radius: 2px; - padding: 14px 17px; + border-radius: 50px; + padding: 15px 32px; height: auto; } &.is-primary { - @include font(13, 20, 400); + @include font(14, 18, 900); &:not(:disabled) { - background-color: $color-blueberry; + background-color: $color-black; } } diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_separator.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_separator.scss index 84e3a1f19..6c3976ed7 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_separator.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_separator.scss @@ -2,13 +2,22 @@ display: flex; align-items: center; + &__space, &__line { - height: 1px; - background-color: $color-gray-600; + margin: 0; display: block; width: 100%; } + &__line { + background-color: $color-gray-400; + height: 1px; + } + + &__space { + margin-bottom: 48px; + } + &__text { color: $color-gray; @include font(12, 24, 500, 0.8px); diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_welcome-docs.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_welcome-docs.scss index e78c940ea..15c5265e6 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_welcome-docs.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_welcome-docs.scss @@ -7,16 +7,6 @@ margin: 0 0 32px 0; } - &__description { - text-align: center; - @include font(14, 22, 400); - font-style: italic; - - a { - color: $color-gray-700; - } - } - &__wrapper { padding: 8px; margin: 0 0 48px 0; diff --git a/modules/ppcp-settings/resources/css/components/screens/_onboarding-global.scss b/modules/ppcp-settings/resources/css/components/screens/_onboarding-global.scss index e98813d14..7878ef729 100644 --- a/modules/ppcp-settings/resources/css/components/screens/_onboarding-global.scss +++ b/modules/ppcp-settings/resources/css/components/screens/_onboarding-global.scss @@ -1,7 +1,8 @@ body:has(.ppcp-r-container--onboarding) { background-color: #fff !important; - .notice, .nav-tab-wrapper.woo-nav-tab-wrapper, .woocommerce-layout, .wrap.woocommerce form > h2, #screen-meta-links { + .notice, .nav-tab-wrapper.woo-nav-tab-wrapper, .woocommerce-layout__header, .wrap.woocommerce form > h2, #screen-meta-links { display: none !important; + visibility: hidden; } } diff --git a/modules/ppcp-settings/resources/css/components/screens/_onboarding.scss b/modules/ppcp-settings/resources/css/components/screens/_onboarding.scss index 5a00adc9d..cfef2e04f 100644 --- a/modules/ppcp-settings/resources/css/components/screens/_onboarding.scss +++ b/modules/ppcp-settings/resources/css/components/screens/_onboarding.scss @@ -1,6 +1,7 @@ @import './onboarding/step-welcome'; @import './onboarding/step-business'; @import './onboarding/step-products'; +@import './onboarding/step-payment-methods'; .ppcp-r-tabs.onboarding, .ppcp-r-container--onboarding { diff --git a/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-payment-methods.scss b/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-payment-methods.scss new file mode 100644 index 000000000..4ab630733 --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-payment-methods.scss @@ -0,0 +1,38 @@ +.ppcp-r-page-optional-payment-methods { + .ppcp-r-select-box:first-child { + .ppcp-r-select-box__title { + margin-bottom: 20px; + } + } +} + +.ppcp-r-optional-payment-methods { + &__wrapper { + .ppcp-r-badge-box { + margin: 0 0 24px 0; + &:last-child { + margin: 0; + } + } + + .ppcp-r-badge-box__description { + margin: 12px 0 0 0; + @include font(14, 20, 400); + } + } + + &__description { + margin: 32px 0 0 0; + text-align: center; + @include font(14, 22, 400); + font-style: italic; + + a { + color: $color-gray-700; + } + } + + &__separator { + margin: 0 0 24px 0; + } +} diff --git a/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-welcome.scss b/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-welcome.scss index fec5f3483..394b2bd10 100644 --- a/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-welcome.scss +++ b/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-welcome.scss @@ -16,21 +16,10 @@ text-align: center; } - .ppcp-r-page-welcome-or-separator { - margin: 0 0 16px 0; - } - - .ppcp-r-page-welcome-mode-separator { - margin: 0 0 48px 0; - - .ppcp-r-separator__line { - background-color: $color-gray-300; - } - } - .components-base-control__field { margin: 0 0 24px 0; } + .ppcp-r-toggle-block__toggled-content > button{ @include small-button; color: $color-white; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/AccordionSection.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/AccordionSection.js index 05cc758db..23f01a09c 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/AccordionSection.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/AccordionSection.js @@ -1,3 +1,4 @@ +import { useEffect } from '@wordpress/element'; import { Icon } from '@wordpress/components'; import { chevronDown, chevronUp } from '@wordpress/icons'; @@ -5,11 +6,33 @@ import { useState } from 'react'; const Accordion = ( { title, - initiallyOpen = false, + initiallyOpen = null, className = '', + id = '', children, } ) => { - const [ isOpen, setIsOpen ] = useState( initiallyOpen ); + const determineInitialState = () => { + if ( id && initiallyOpen === null ) { + return window.location.hash === `#${ id }`; + } + return !! initiallyOpen; + }; + + const [ isOpen, setIsOpen ] = useState( determineInitialState ); + + useEffect( () => { + const handleHashChange = () => { + if ( id && window.location.hash === `#${ id }` ) { + setIsOpen( true ); + } + }; + + window.addEventListener( 'hashchange', handleHashChange ); + + return () => { + window.removeEventListener( 'hashchange', handleHashChange ); + }; + }, [ id ] ); const toggleOpen = ( ev ) => { setIsOpen( ! isOpen ); @@ -26,7 +49,7 @@ const Accordion = ( { } return ( -
+
- + { onChange={ setClientSecret } type="password" /> - diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Navigation.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js similarity index 91% rename from modules/ppcp-settings/resources/js/Components/ReusableComponents/Navigation.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js index 18b13e976..5a1da25cb 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Navigation.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js @@ -1,10 +1,8 @@ import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { - useOnboardingStepBusiness, - useOnboardingStepProducts, -} from '../../data'; -import data from '../../utils/data'; + +import { OnboardingHooks } from '../../../../data'; +import data from '../../../../utils/data'; const Navigation = ( { setStep, setCompleted, currentStep, stepperOrder } ) => { const isLastStep = () => currentStep + 1 === stepperOrder.length; @@ -24,8 +22,8 @@ const Navigation = ( { setStep, setCompleted, currentStep, stepperOrder } ) => { } }; - const { products, toggleProduct } = useOnboardingStepProducts(); - const { isCasualSeller, setIsCasualSeller } = useOnboardingStepBusiness(); + const { products } = OnboardingHooks.useProducts(); + const { isCasualSeller } = OnboardingHooks.useBusiness(); let navigationTitle = ''; let disabled = false; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js index a91eabb48..30cd52ffe 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js @@ -1,7 +1,7 @@ import Container from '../../ReusableComponents/Container'; -import { useOnboardingStep } from '../../../data'; +import { OnboardingHooks } from '../../../data'; import { getSteps } from './availableSteps'; -import Navigation from '../../ReusableComponents/Navigation'; +import Navigation from './Components/Navigation'; const getCurrentStep = ( requestedStep, steps ) => { const isValidStep = ( step ) => @@ -15,7 +15,7 @@ const getCurrentStep = ( requestedStep, steps ) => { }; const Onboarding = () => { - const { step, setStep, setCompleted, flags } = useOnboardingStep(); + const { step, setStep, setCompleted, flags } = OnboardingHooks.useSteps(); const steps = getSteps( flags ); const CurrentStepComponent = getCurrentStep( step, steps ); diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepBusiness.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepBusiness.js index 670a65ae6..a223686ff 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepBusiness.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepBusiness.js @@ -1,20 +1,14 @@ +import { __ } from '@wordpress/i18n'; + import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper'; import SelectBox from '../../ReusableComponents/SelectBox'; -import { __ } from '@wordpress/i18n'; -import PaymentMethodIcons from '../../ReusableComponents/PaymentMethodIcons'; -import { useOnboardingStepBusiness } from '../../../data'; -import { BUSINESS_TYPES } from '../../../data/constants'; +import { OnboardingHooks, BUSINESS_TYPES } from '../../../data'; const BUSINESS_RADIO_GROUP_NAME = 'business'; -const StepBusiness = ( { - setStep, - currentStep, - stepperOrder, - setCompleted, -} ) => { - const { isCasualSeller, setIsCasualSeller } = useOnboardingStepBusiness(); +const StepBusiness = ( {} ) => { + const { isCasualSeller, setIsCasualSeller } = OnboardingHooks.useBusiness(); const handleSellerTypeChange = ( value ) => { setIsCasualSeller( BUSINESS_TYPES.CASUAL_SELLER === value ); @@ -40,23 +34,22 @@ const StepBusiness = ( { />
- - + - + >
diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js index 5dc18f619..5f63f923e 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js @@ -1,31 +1,23 @@ -import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; import { __ } from '@wordpress/i18n'; import { Button, Icon } from '@wordpress/components'; -const StepCompleteSetup = ( { - setStep, - currentStep, - stepperOrder, - setCompleted, -} ) => { +import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; + +const StepCompleteSetup = ( { setCompleted } ) => { const ButtonIcon = () => ( ( - - + ) } /> diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepPaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepPaymentMethods.js new file mode 100644 index 000000000..a9d2f6b9e --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepPaymentMethods.js @@ -0,0 +1,78 @@ +import { __, sprintf } from '@wordpress/i18n'; + +import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; +import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper'; +import SelectBox from '../../ReusableComponents/SelectBox'; +import { OnboardingHooks } from '../../../data'; +import OptionalPaymentMethods from '../../ReusableComponents/OptionalPaymentMethods/OptionalPaymentMethods'; + +const OPM_RADIO_GROUP_NAME = 'optional-payment-methods'; + +const StepPaymentMethods = ( {} ) => { + const { + areOptionalPaymentMethodsEnabled, + setAreOptionalPaymentMethodsEnabled, + } = OnboardingHooks.useOptionalPaymentMethods(); + const pricesBasedDescription = sprintf( + // translators: %s: Link to PayPal REST application guide + __( + '1Prices based on domestic transactions as of October 25th, 2024. Click here for full pricing details.', + 'woocommerce-paypal-payments' + ), + 'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input ' + ); + + return ( +
+ +
+ + + } + name={ OPM_RADIO_GROUP_NAME } + value={ true } + changeCallback={ setAreOptionalPaymentMethodsEnabled } + currentValue={ areOptionalPaymentMethodsEnabled } + type="radio" + > + + +

+
+
+ ); +}; + +export default StepPaymentMethods; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepProducts.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepProducts.js index 9a705122d..cbd642327 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepProducts.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepProducts.js @@ -1,19 +1,14 @@ -import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; import { __ } from '@wordpress/i18n'; + +import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; import SelectBox from '../../ReusableComponents/SelectBox'; import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper'; -import { useOnboardingStepProducts } from '../../../data'; -import { PRODUCT_TYPES } from '../../../data/constants'; +import { OnboardingHooks, PRODUCT_TYPES } from '../../../data'; const PRODUCTS_CHECKBOX_GROUP_NAME = 'products'; -const StepProducts = ( { - setStep, - currentStep, - stepperOrder, - setCompleted, -} ) => { - const { products, toggleProduct } = useOnboardingStepProducts(); +const StepProducts = () => { + const { products, setProducts } = OnboardingHooks.useProducts(); return (
@@ -33,7 +28,7 @@ const StepProducts = ( { ) } name={ PRODUCTS_CHECKBOX_GROUP_NAME } value={ PRODUCT_TYPES.VIRTUAL } - changeCallback={ toggleProduct } + changeCallback={ setProducts } currentValue={ products } type="checkbox" > @@ -75,7 +70,7 @@ const StepProducts = ( { ) } name={ PRODUCTS_CHECKBOX_GROUP_NAME } value={ PRODUCT_TYPES.PHYSICAL } - changeCallback={ toggleProduct } + changeCallback={ setProducts } currentValue={ products } type="checkbox" > @@ -102,7 +97,7 @@ const StepProducts = ( { ) } name={ PRODUCTS_CHECKBOX_GROUP_NAME } value={ PRODUCT_TYPES.SUBSCRIPTIONS } - changeCallback={ toggleProduct } + changeCallback={ setProducts } currentValue={ products } type="checkbox" > diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js index 1d0c07b32..c94c84935 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js @@ -1,13 +1,13 @@ -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; import PaymentMethodIcons from '../../ReusableComponents/PaymentMethodIcons'; import Separator from '../../ReusableComponents/Separator'; import WelcomeDocs from '../../ReusableComponents/WelcomeDocs/WelcomeDocs'; +import AccordionSection from '../../ReusableComponents/AccordionSection'; import AdvancedOptionsForm from './Components/AdvancedOptionsForm'; -import AccordionSection from '../../ReusableComponents/AccordionSection'; const StepWelcome = ( { setStep, currentStep, setCompleted } ) => { return ( @@ -57,7 +57,7 @@ const StepWelcome = ( { setStep, currentStep, setCompleted } ) => { 'woocommerce-paypal-payments' ) } className="onboarding-advanced-options" - initiallyOpen={ false } + id="advanced-options" > diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/availableSteps.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/availableSteps.js index a5555180d..7e8ea1556 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/availableSteps.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/availableSteps.js @@ -1,6 +1,7 @@ import StepWelcome from './StepWelcome'; import StepBusiness from './StepBusiness'; import StepProducts from './StepProducts'; +import StepPaymentMethods from './StepPaymentMethods'; import StepCompleteSetup from './StepCompleteSetup'; export const getSteps = ( flags ) => { @@ -8,6 +9,7 @@ export const getSteps = ( flags ) => { StepWelcome, StepBusiness, StepProducts, + StepPaymentMethods, StepCompleteSetup, ]; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings.js index 2f2357951..e0634343c 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Settings.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Settings.js @@ -1,9 +1,9 @@ -import { useOnboardingStep } from '../../data'; +import { OnboardingHooks } from '../../data'; import Onboarding from './Onboarding/Onboarding'; import SettingsScreen from './SettingsScreen'; const Settings = () => { - const onboardingProgress = useOnboardingStep(); + const onboardingProgress = OnboardingHooks.useSteps(); if ( ! onboardingProgress.isReady ) { // TODO: Use better loading state indicator. diff --git a/modules/ppcp-settings/resources/js/data/common/action-types.js b/modules/ppcp-settings/resources/js/data/common/action-types.js new file mode 100644 index 000000000..47de76afe --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/action-types.js @@ -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', +}; diff --git a/modules/ppcp-settings/resources/js/data/common/actions.js b/modules/ppcp-settings/resources/js/data/common/actions.js new file mode 100644 index 000000000..619aaca5f --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/actions.js @@ -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; +}; diff --git a/modules/ppcp-settings/resources/js/data/common/constants.js b/modules/ppcp-settings/resources/js/data/common/constants.js new file mode 100644 index 000000000..c7ea9b4c1 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/constants.js @@ -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'; diff --git a/modules/ppcp-settings/resources/js/data/common/controls.js b/modules/ppcp-settings/resources/js/data/common/controls.js new file mode 100644 index 000000000..6de513e0b --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/controls.js @@ -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; + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/common/hooks.js b/modules/ppcp-settings/resources/js/data/common/hooks.js new file mode 100644 index 000000000..8be3857b0 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/hooks.js @@ -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, + }; +}; diff --git a/modules/ppcp-settings/resources/js/data/common/index.js b/modules/ppcp-settings/resources/js/data/common/index.js new file mode 100644 index 000000000..28c162f98 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/index.js @@ -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 }; diff --git a/modules/ppcp-settings/resources/js/data/common/reducer.js b/modules/ppcp-settings/resources/js/data/common/reducer.js new file mode 100644 index 000000000..3f822468b --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/reducer.js @@ -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; diff --git a/modules/ppcp-settings/resources/js/data/common/resolvers.js b/modules/ppcp-settings/resources/js/data/common/resolvers.js new file mode 100644 index 000000000..ceebca53f --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/resolvers.js @@ -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' + ) + ); + } + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/common/selectors.js b/modules/ppcp-settings/resources/js/data/common/selectors.js new file mode 100644 index 000000000..14334fcf3 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/common/selectors.js @@ -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; +}; diff --git a/modules/ppcp-settings/resources/js/data/constants.js b/modules/ppcp-settings/resources/js/data/constants.js index e6f8f9de5..5654ad476 100644 --- a/modules/ppcp-settings/resources/js/data/constants.js +++ b/modules/ppcp-settings/resources/js/data/constants.js @@ -1,6 +1,3 @@ -export const NAMESPACE = '/wc/v3/wc_paypal'; -export const STORE_NAME = 'wc/paypal'; - export const BUSINESS_TYPES = { CASUAL_SELLER: 'casual_seller', BUSINESS: 'business', diff --git a/modules/ppcp-settings/resources/js/data/debug.js b/modules/ppcp-settings/resources/js/data/debug.js new file mode 100644 index 000000000..b292d1920 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/debug.js @@ -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(); + }; +}; diff --git a/modules/ppcp-settings/resources/js/data/index.js b/modules/ppcp-settings/resources/js/data/index.js index 1c95c4261..274aac790 100644 --- a/modules/ppcp-settings/resources/js/data/index.js +++ b/modules/ppcp-settings/resources/js/data/index.js @@ -1,7 +1,16 @@ -import { STORE_NAME } from './constants'; -import { initStore } from './store'; +import { addDebugTools } from './debug'; +import * as Onboarding from './onboarding'; +import * as Common from './common'; -initStore(); +Onboarding.initStore(); +Common.initStore(); -export const WC_PAYPAL_STORE_NAME = STORE_NAME; -export * from './onboarding/hooks'; +export const OnboardingHooks = Onboarding.hooks; +export const CommonHooks = Common.hooks; + +export const OnboardingStoreName = Onboarding.STORE_NAME; +export const CommonStoreName = Common.STORE_NAME; + +export * from './constants'; + +addDebugTools( window.ppcpSettings, [ Onboarding, Common ] ); diff --git a/modules/ppcp-settings/resources/js/data/onboarding/action-types.js b/modules/ppcp-settings/resources/js/data/onboarding/action-types.js index 39472e2ff..2e16f8468 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/action-types.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/action-types.js @@ -1,19 +1,18 @@ -export default { - RESET_ONBOARDING: 'RESET_ONBOARDING', +/** + * Action Types: Define unique identifiers for actions across all store modules. + * + * @file + */ +export default { // Transient data. - SET_ONBOARDING_IS_READY: 'SET_ONBOARDING_IS_READY', - SET_IS_SAVING_ONBOARDING: 'SET_IS_SAVING_ONBOARDING', - SET_MANUAL_CONNECTION_BUSY: 'SET_MANUAL_CONNECTION_BUSY', + SET_TRANSIENT: 'ONBOARDING:SET_TRANSIENT', // Persistent data. - SET_ONBOARDING_COMPLETED: 'SET_ONBOARDING_COMPLETED', - SET_ONBOARDING_DETAILS: 'SET_ONBOARDING_DETAILS', - SET_ONBOARDING_STEP: 'SET_ONBOARDING_STEP', - SET_SANDBOX_MODE: 'SET_SANDBOX_MODE', - SET_MANUAL_CONNECTION_MODE: 'SET_MANUAL_CONNECTION_MODE', - SET_CLIENT_ID: 'SET_CLIENT_ID', - SET_CLIENT_SECRET: 'SET_CLIENT_SECRET', - SET_IS_CASUAL_SELLER: 'SET_IS_CASUAL_SELLER', - SET_PRODUCTS: 'SET_PRODUCTS', + SET_PERSISTENT: 'ONBOARDING:SET_PERSISTENT', + RESET: 'ONBOARDING:RESET', + HYDRATE: 'ONBOARDING:HYDRATE', + + // Controls - always start with "DO_". + DO_PERSIST_DATA: 'ONBOARDING:DO_PERSIST_DATA', }; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/actions.js b/modules/ppcp-settings/resources/js/data/onboarding/actions.js index 09229e63e..dcf401995 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/actions.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/actions.js @@ -1,235 +1,116 @@ +/** + * Action Creators: Define functions to create action objects. + * + * These functions update state or trigger side effects (e.g., async operations). + * Actions are categorized as Transient, Persistent, or Side effect. + * + * @file + */ + import { select } from '@wordpress/data'; -import { apiFetch } from '@wordpress/data-controls'; + import ACTION_TYPES from './action-types'; -import { NAMESPACE, STORE_NAME } from '../constants'; +import { STORE_NAME } from './constants'; + +/** + * @typedef {Object} Action An action object that is handled by a reducer or control. + * @property {string} type - The action type. + * @property {Object?} payload - Optional payload for the action. + */ /** * Special. Resets all values in the onboarding store to initial defaults. * - * @return {{type: string}} The action. + * @return {Action} The action. */ -export const resetOnboarding = () => { - return { type: ACTION_TYPES.RESET_ONBOARDING }; -}; - -/** - * Non-persistent. Marks the onboarding details as "ready", i.e., fully initialized. - * - * @param {boolean} isReady - * @return {{type: string, isReady}} The action. - */ -export const setIsReady = ( isReady ) => { - return { - type: ACTION_TYPES.SET_ONBOARDING_IS_READY, - isReady, - }; -}; - -/** - * Non-persistent. Changes the "saving" flag. - * - * @param {boolean} isSaving - * @return {{type: string, isSaving}} The action. - */ -export const setIsSaving = ( isSaving ) => { - return { - type: ACTION_TYPES.SET_IS_SAVING_ONBOARDING, - isSaving, - }; -}; - -/** - * Non-persistent. Changes the "manual connection is busy" flag. - * - * @param {boolean} isBusy - * @return {{type: string, isBusy}} The action. - */ -export const setManualConnectionIsBusy = ( isBusy ) => { - return { - type: ACTION_TYPES.SET_MANUAL_CONNECTION_BUSY, - isBusy, - }; -}; +export const reset = () => ( { type: ACTION_TYPES.RESET } ); /** * Persistent. Set the full onboarding details, usually during app initialization. * * @param {{data: {}, flags?: {}}} payload - * @return {{type: string, payload}} The action. + * @return {Action} The action. */ -export const setOnboardingDetails = ( payload ) => { - return { - type: ACTION_TYPES.SET_ONBOARDING_DETAILS, - payload, - }; -}; +export const hydrate = ( payload ) => ( { + type: ACTION_TYPES.HYDRATE, + payload, +} ); + +/** + * Transient. Marks the onboarding details as "ready", i.e., fully initialized. + * + * @param {boolean} isReady + * @return {Action} The action. + */ +export const setIsReady = ( isReady ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { isReady }, +} ); /** * Persistent.Set the "onboarding completed" flag which shows or hides the wizard. * * @param {boolean} completed - * @return {{type: string, payload}} The action. + * @return {Action} The action. */ -export const setCompleted = ( completed ) => { - return { - type: ACTION_TYPES.SET_ONBOARDING_COMPLETED, - completed, - }; -}; +export const setCompleted = ( completed ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { completed }, +} ); /** * Persistent. Sets the onboarding wizard to a new step. * * @param {number} step - * @return {{type: string, step}} An action. + * @return {Action} The action. */ -export const setOnboardingStep = ( step ) => { - return { - type: ACTION_TYPES.SET_ONBOARDING_STEP, - step, - }; -}; - -/** - * Persistent. Sets the sandbox mode on or off. - * - * @param {boolean} sandboxMode - * @return {{type: string, useSandbox}} An action. - */ -export const setSandboxMode = ( sandboxMode ) => { - return { - type: ACTION_TYPES.SET_SANDBOX_MODE, - useSandbox: sandboxMode, - }; -}; - -/** - * Persistent. Toggles the "Manual Connection" mode on or off. - * - * @param {boolean} manualConnectionMode - * @return {{type: string, useManualConnection}} An action. - */ -export const setManualConnectionMode = ( manualConnectionMode ) => { - return { - type: ACTION_TYPES.SET_MANUAL_CONNECTION_MODE, - useManualConnection: manualConnectionMode, - }; -}; - -/** - * Persistent. Changes the "client ID" value. - * - * @param {string} clientId - * @return {{type: string, clientId}} The action. - */ -export const setClientId = ( clientId ) => { - return { - type: ACTION_TYPES.SET_CLIENT_ID, - clientId, - }; -}; - -/** - * Persistent. Changes the "client secret" value. - * - * @param {string} clientSecret - * @return {{type: string, clientSecret}} The action. - */ -export const setClientSecret = ( clientSecret ) => { - return { - type: ACTION_TYPES.SET_CLIENT_SECRET, - clientSecret, - }; -}; +export const setStep = ( step ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { step }, +} ); /** * Persistent. Sets the "isCasualSeller" value. * * @param {boolean} isCasualSeller - * @return {{type: string, isCasualSeller}} The action. + * @return {Action} The action. */ -export const setIsCasualSeller = ( isCasualSeller ) => { - return { - type: ACTION_TYPES.SET_IS_CASUAL_SELLER, - isCasualSeller, - }; -}; +export const setIsCasualSeller = ( isCasualSeller ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { isCasualSeller }, +} ); + +/** + * Persistent. Sets the "areOptionalPaymentMethodsEnabled" value. + * + * @param {boolean} areOptionalPaymentMethodsEnabled + * @return {Action} The action. + */ +export const setAreOptionalPaymentMethodsEnabled = ( + areOptionalPaymentMethodsEnabled +) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { areOptionalPaymentMethodsEnabled }, +} ); /** * Persistent. Sets the "products" array. * * @param {string[]} products - * @return {{type: string, products}} The action. + * @return {Action} The action. */ -export const setProducts = ( products ) => { - return { - type: ACTION_TYPES.SET_PRODUCTS, - products, - }; +export const setProducts = ( products ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { products }, +} ); + +/** + * Side effect. Triggers the persistence of onboarding data to the server. + * + * @return {Action} The action. + */ +export const persist = function* () { + const data = yield select( STORE_NAME ).persistentData(); + + yield { type: ACTION_TYPES.DO_PERSIST_DATA, data }; }; - -/** - * Attempts to establish a connection using client ID and secret via the server-side - * connection endpoint. - * - * @return {Object} The server response object - */ -export function* connectViaIdAndSecret() { - let result = null; - - try { - const path = `${ NAMESPACE }/connect_manual`; - const { clientId, clientSecret, useSandbox } = - yield select( STORE_NAME ).getPersistentData(); - - yield setManualConnectionIsBusy( true ); - - result = yield apiFetch( { - path, - method: 'POST', - data: { - clientId, - clientSecret, - useSandbox, - }, - } ); - } catch ( e ) { - result = { - success: false, - error: e, - }; - } finally { - yield setManualConnectionIsBusy( false ); - } - - return result; -} - -/** - * Saves the persistent details to the WP database. - * - * @return {boolean} True, if the values were successfully saved. - */ -export function* persist() { - let error = null; - - try { - const path = `${ NAMESPACE }/onboarding`; - const data = select( STORE_NAME ).getPersistentData(); - - yield setIsSaving( true ); - - yield apiFetch( { - path, - method: 'post', - data, - } ); - } catch ( e ) { - error = e; - console.error( 'Error saving progress.', e ); - } finally { - yield setIsSaving( false ); - } - - return error === null; -} diff --git a/modules/ppcp-settings/resources/js/data/onboarding/constants.js b/modules/ppcp-settings/resources/js/data/onboarding/constants.js new file mode 100644 index 000000000..4b33c6701 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/onboarding/constants.js @@ -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'; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/controls.js b/modules/ppcp-settings/resources/js/data/onboarding/controls.js new file mode 100644 index 000000000..30f1cce48 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/onboarding/controls.js @@ -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 ); + } + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js index ff9052d69..4ae5bd947 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js @@ -1,163 +1,115 @@ -import { useSelect, useDispatch } from '@wordpress/data'; -import apiFetch from '@wordpress/api-fetch'; -import { NAMESPACE, PRODUCT_TYPES, STORE_NAME } from '../constants'; -import { getFlags } from './selectors'; +/** + * 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 + */ -const useOnboardingDetails = () => { +import { useSelect, useDispatch } from '@wordpress/data'; + +import { PRODUCT_TYPES } from '../constants'; +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, - setOnboardingStep, + setStep, setCompleted, - setSandboxMode, - setManualConnectionMode, - setClientId, - setClientSecret, setIsCasualSeller, + setAreOptionalPaymentMethodsEnabled, setProducts, } = useDispatch( STORE_NAME ); - // Transient accessors. - const isSaving = useSelect( ( select ) => { - return select( STORE_NAME ).getTransientData().isSaving; - }, [] ); - - const isReady = useSelect( ( select ) => { - return select( STORE_NAME ).getTransientData().isReady; - } ); - - const isManualConnectionBusy = useSelect( ( select ) => { - return select( STORE_NAME ).getTransientData().isManualConnectionBusy; - }, [] ); - // Read-only flags. - const flags = useSelect( ( select ) => { - return select( STORE_NAME ).getFlags(); - } ); + const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] ); + + // Transient accessors. + const isReady = useTransient( 'isReady' ); // Persistent accessors. - const step = useSelect( ( select ) => { - return select( STORE_NAME ).getPersistentData().step || 0; - } ); + const step = usePersistent( 'step' ); + const completed = usePersistent( 'completed' ); + const isCasualSeller = usePersistent( 'isCasualSeller' ); + const areOptionalPaymentMethodsEnabled = usePersistent( + 'areOptionalPaymentMethodsEnabled' + ); + const products = usePersistent( 'products' ); - const completed = useSelect( ( select ) => { - return select( STORE_NAME ).getPersistentData().completed; - } ); - - const clientId = useSelect( ( select ) => { - return select( STORE_NAME ).getPersistentData().clientId; - }, [] ); - - const clientSecret = useSelect( ( select ) => { - return select( STORE_NAME ).getPersistentData().clientSecret; - }, [] ); - - const isSandboxMode = useSelect( ( select ) => { - return select( STORE_NAME ).getPersistentData().useSandbox; - }, [] ); - - const isManualConnectionMode = useSelect( ( select ) => { - return select( STORE_NAME ).getPersistentData().useManualConnection; - }, [] ); - - const isCasualSeller = useSelect( ( select ) => { - return select( STORE_NAME ).getPersistentData().isCasualSeller; - }, [] ); - - const products = useSelect( ( select ) => { - return select( STORE_NAME ).getPersistentData().products || []; - }, [] ); - - const toggleProduct = ( list ) => { - const validProducts = list.filter( ( item ) => - Object.values( PRODUCT_TYPES ).includes( item ) - ); - return setDetailAndPersist( setProducts, validProducts ); - }; - - const setDetailAndPersist = async ( setter, value ) => { + const savePersistent = async ( setter, value ) => { setter( value ); await persist(); }; return { - isSaving, - isReady, - isManualConnectionBusy, - step, - setStep: ( value ) => setDetailAndPersist( setOnboardingStep, value ), - completed, - setCompleted: ( state ) => setDetailAndPersist( setCompleted, state ), - isSandboxMode, - setSandboxMode: ( state ) => - setDetailAndPersist( setSandboxMode, state ), - isManualConnectionMode, - setManualConnectionMode: ( state ) => - setDetailAndPersist( setManualConnectionMode, state ), - clientId, - setClientId: ( value ) => setDetailAndPersist( setClientId, value ), - clientSecret, - setClientSecret: ( value ) => - setDetailAndPersist( setClientSecret, value ), - isCasualSeller, - setIsCasualSeller: ( value ) => - setDetailAndPersist( setIsCasualSeller, value ), - products, - toggleProduct, flags, + isReady, + step, + setStep: ( value ) => { + return savePersistent( setStep, value ); + }, + completed, + setCompleted: ( state ) => { + return savePersistent( setCompleted, state ); + }, + isCasualSeller, + setIsCasualSeller: ( value ) => { + return savePersistent( setIsCasualSeller, value ); + }, + areOptionalPaymentMethodsEnabled, + setAreOptionalPaymentMethodsEnabled: ( value ) => { + return savePersistent( setAreOptionalPaymentMethodsEnabled, value ); + }, + products, + setProducts: ( activeProducts ) => { + const validProducts = activeProducts.filter( ( item ) => + Object.values( PRODUCT_TYPES ).includes( item ) + ); + return savePersistent( setProducts, validProducts ); + }, }; }; -export const useOnboardingStepWelcome = () => { - const { - isSaving, - isManualConnectionBusy, - isSandboxMode, - setSandboxMode, - isManualConnectionMode, - setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - } = useOnboardingDetails(); - - return { - isSaving, - isManualConnectionBusy, - isSandboxMode, - setSandboxMode, - isManualConnectionMode, - setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - }; -}; - -export const useOnboardingStepBusiness = () => { - const { isCasualSeller, setIsCasualSeller } = useOnboardingDetails(); +export const useBusiness = () => { + const { isCasualSeller, setIsCasualSeller } = useHooks(); return { isCasualSeller, setIsCasualSeller }; }; -export const useOnboardingStepProducts = () => { - const { products, toggleProduct } = useOnboardingDetails(); +export const useProducts = () => { + const { products, setProducts } = useHooks(); - return { products, toggleProduct }; + return { products, setProducts }; }; -export const useOnboardingStep = () => { - const { isReady, step, setStep, completed, setCompleted, flags } = - useOnboardingDetails(); - - return { isReady, step, setStep, completed, setCompleted, flags }; -}; - -export const useManualConnect = () => { - const { connectViaIdAndSecret } = useDispatch( STORE_NAME ); +export const useOptionalPaymentMethods = () => { + const { + areOptionalPaymentMethodsEnabled, + setAreOptionalPaymentMethodsEnabled, + } = useHooks(); return { - connectManual: connectViaIdAndSecret, + areOptionalPaymentMethodsEnabled, + setAreOptionalPaymentMethodsEnabled, }; }; + +export const useSteps = () => { + const { flags, isReady, step, setStep, completed, setCompleted } = + useHooks(); + + return { flags, isReady, step, setStep, completed, setCompleted }; +}; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/index.js b/modules/ppcp-settings/resources/js/data/onboarding/index.js index 0b07abf46..28c162f98 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/index.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/index.js @@ -1,6 +1,24 @@ +import { createReduxStore, register } from '@wordpress/data'; +import { controls as wpControls } from '@wordpress/data-controls'; + +import { STORE_NAME } from './constants'; import reducer from './reducer'; import * as selectors from './selectors'; import * as actions from './actions'; -import * as resolvers from './resolvers'; +import * as hooks from './hooks'; +import { resolvers } from './resolvers'; +import { controls } from './controls'; -export { reducer, selectors, actions, resolvers }; +export const initStore = () => { + const store = createReduxStore( STORE_NAME, { + reducer, + controls: { ...wpControls, ...controls }, + actions, + selectors, + resolvers, + } ); + + register( store ); +}; + +export { hooks, selectors, STORE_NAME }; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/reducer.js b/modules/ppcp-settings/resources/js/data/onboarding/reducer.js index 5c1f59263..176d4875d 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/reducer.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/reducer.js @@ -1,21 +1,19 @@ +/** + * Reducer: Defines store structure and state updates for this module. + * + * Manages both transient (temporary) and persistent (saved) state. + * The initial state must define all properties, as dynamic additions are not supported. + * + * @file + */ + +import { createReducer, createSetters } from '../utils'; import ACTION_TYPES from './action-types'; -const defaultState = { - isReady: false, - isSaving: false, - isManualConnectionBusy: false, +// Store structure. - // Data persisted to the server. - data: { - completed: false, - step: 0, - useSandbox: false, - useManualConnection: false, - clientId: '', - clientSecret: '', - isCasualSeller: null, // null value will uncheck both options in the UI. - products: [], - }, +const defaultTransient = { + isReady: false, // Read only values, provided by the server. flags: { @@ -25,83 +23,41 @@ const defaultState = { }, }; -export const onboardingReducer = ( - state = defaultState, - { type, ...action } -) => { - const setTransient = ( changes ) => { - const { data, ...transientChanges } = changes; - return { ...state, ...transientChanges }; - }; - - const setPersistent = ( changes ) => { - const validChanges = Object.keys( changes ).reduce( ( acc, key ) => { - if ( key in defaultState.data ) { - acc[ key ] = changes[ key ]; - } - return acc; - }, {} ); - - return { - ...state, - data: { ...state.data, ...validChanges }, - }; - }; - - switch ( type ) { - // Reset store to initial state. - case ACTION_TYPES.RESET_ONBOARDING: - return setPersistent( defaultState.data ); - - // Transient data. - case ACTION_TYPES.SET_ONBOARDING_IS_READY: - return setTransient( { isReady: action.isReady } ); - - case ACTION_TYPES.SET_IS_SAVING_ONBOARDING: - return setTransient( { isSaving: action.isSaving } ); - - case ACTION_TYPES.SET_MANUAL_CONNECTION_BUSY: - return setTransient( { isManualConnectionBusy: action.isBusy } ); - - // Persistent data. - case ACTION_TYPES.SET_ONBOARDING_DETAILS: - const newState = setPersistent( action.payload.data ); - - if ( action.payload.flags ) { - newState.flags = { ...newState.flags, ...action.payload.flags }; - } - - return newState; - - case ACTION_TYPES.SET_ONBOARDING_COMPLETED: - return setPersistent( { completed: action.completed } ); - - case ACTION_TYPES.SET_CLIENT_ID: - return setPersistent( { clientId: action.clientId } ); - - case ACTION_TYPES.SET_CLIENT_SECRET: - return setPersistent( { clientSecret: action.clientSecret } ); - - case ACTION_TYPES.SET_ONBOARDING_STEP: - return setPersistent( { step: action.step } ); - - case ACTION_TYPES.SET_SANDBOX_MODE: - return setPersistent( { useSandbox: action.useSandbox } ); - - case ACTION_TYPES.SET_MANUAL_CONNECTION_MODE: - return setPersistent( { - useManualConnection: action.useManualConnection, - } ); - - case ACTION_TYPES.SET_IS_CASUAL_SELLER: - return setPersistent( { isCasualSeller: action.isCasualSeller } ); - - case ACTION_TYPES.SET_PRODUCTS: - return setPersistent( { products: action.products } ); - - default: - return state; - } +const defaultPersistent = { + completed: false, + step: 0, + isCasualSeller: null, // null value will uncheck both options in the UI. + areOptionalPaymentMethodsEnabled: true, + products: [], }; +// Reducer logic. + +const [ setTransient, setPersistent ] = createSetters( + defaultTransient, + defaultPersistent +); + +const onboardingReducer = createReducer( defaultTransient, defaultPersistent, { + [ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) => + setTransient( state, payload ), + + [ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) => + setPersistent( state, payload ), + + [ ACTION_TYPES.RESET ]: ( state ) => + setPersistent( state, defaultPersistent ), + + [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => { + const newState = setPersistent( state, payload.data ); + + // Flags are not updated by `setPersistent()`. + if ( payload.flags ) { + newState.flags = { ...newState.flags, ...payload.flags }; + } + + return newState; + }, +} ); + export default onboardingReducer; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/resolvers.js b/modules/ppcp-settings/resources/js/data/onboarding/resolvers.js index 18f2a7528..bf7828dd3 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/resolvers.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/resolvers.js @@ -1,25 +1,36 @@ +/** + * Resolvers: Handle asynchronous data fetching for the store. + * + * These functions update store state with data from external sources. + * Each resolver corresponds to a specific selector (selector with same name must exist). + * Resolvers are called automatically when selectors request unavailable data. + * + * @file + */ + import { dispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { apiFetch } from '@wordpress/data-controls'; -import { NAMESPACE } from '../constants'; -import { setIsReady, setOnboardingDetails } from './actions'; -/** - * Retrieve settings from the site's REST API. - */ -export function* getPersistentData() { - const path = `${ NAMESPACE }/onboarding`; +import { STORE_NAME, REST_HYDRATE_PATH } from './constants'; - try { - const result = yield apiFetch( { path } ); - yield setOnboardingDetails( result ); - yield setIsReady( true ); - } catch ( e ) { - yield dispatch( 'core/notices' ).createErrorNotice( - __( - 'Error retrieving onboarding details.', - 'woocommerce-paypal-payments' - ) - ); - } -} +export const resolvers = { + /** + * Retrieve settings from the site's REST API. + */ + *persistentData() { + try { + const result = yield apiFetch( { path: REST_HYDRATE_PATH } ); + + yield dispatch( STORE_NAME ).hydrate( result ); + yield dispatch( STORE_NAME ).setIsReady( true ); + } catch ( e ) { + yield dispatch( 'core/notices' ).createErrorNotice( + __( + 'Error retrieving onboarding details.', + 'woocommerce-paypal-payments' + ) + ); + } + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js index b7721b992..d4d57ef4d 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js @@ -1,22 +1,25 @@ +/** + * Selectors: Extract specific pieces of state from the store. + * + * These functions provide a consistent interface for accessing store data. + * They allow components to retrieve data without knowing the store structure. + * + * @file + */ + const EMPTY_OBJ = Object.freeze( {} ); -const getOnboardingState = ( state ) => { - if ( ! state ) { - return EMPTY_OBJ; - } +const getState = ( state ) => state || EMPTY_OBJ; - return state.onboarding || EMPTY_OBJ; +export const persistentData = ( state ) => { + return getState( state ).data || EMPTY_OBJ; }; -export const getPersistentData = ( state ) => { - return getOnboardingState( state ).data || EMPTY_OBJ; -}; - -export const getTransientData = ( state ) => { - const { data, flags, ...transientState } = getOnboardingState( state ); +export const transientData = ( state ) => { + const { data, flags, ...transientState } = getState( state ); return transientState || EMPTY_OBJ; }; -export const getFlags = ( state ) => { - return getOnboardingState( state ).flags || EMPTY_OBJ; +export const flags = ( state ) => { + return getState( state ).flags || EMPTY_OBJ; }; diff --git a/modules/ppcp-settings/resources/js/data/store.js b/modules/ppcp-settings/resources/js/data/store.js deleted file mode 100644 index a4acaf548..000000000 --- a/modules/ppcp-settings/resources/js/data/store.js +++ /dev/null @@ -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 */ -}; diff --git a/modules/ppcp-settings/resources/js/data/utils.js b/modules/ppcp-settings/resources/js/data/utils.js new file mode 100644 index 000000000..45c652862 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/utils.js @@ -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; + }; +}; diff --git a/modules/ppcp-settings/resources/js/utils/window.js b/modules/ppcp-settings/resources/js/utils/window.js new file mode 100644 index 000000000..165874302 --- /dev/null +++ b/modules/ppcp-settings/resources/js/utils/window.js @@ -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; +}; diff --git a/modules/ppcp-settings/services.php b/modules/ppcp-settings/services.php index 20ea96b09..d213aa4c0 100644 --- a/modules/ppcp-settings/services.php +++ b/modules/ppcp-settings/services.php @@ -9,11 +9,17 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings; -use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint; -use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; -use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; -use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; +use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings; +use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings; use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile; +use WooCommerce\PayPalCommerce\Settings\Endpoint\CommonRestEndpoint; +use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint; +use WooCommerce\PayPalCommerce\Settings\Endpoint\LoginLinkRestEndpoint; +use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint; +use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; +use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator; +use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; return array( 'settings.url' => static function ( ContainerInterface $container ) : string { @@ -44,14 +50,29 @@ return array( $can_use_card_payments ); }, + 'settings.data.general' => static function ( ContainerInterface $container ) : GeneralSettings { + return new GeneralSettings(); + }, + 'settings.data.common' => static function ( ContainerInterface $container ) : CommonSettings { + return new CommonSettings(); + }, 'settings.rest.onboarding' => static function ( ContainerInterface $container ) : OnboardingRestEndpoint { return new OnboardingRestEndpoint( $container->get( 'settings.data.onboarding' ) ); }, + 'settings.rest.common' => static function ( ContainerInterface $container ) : CommonRestEndpoint { + return new CommonRestEndpoint( $container->get( 'settings.data.common' ) ); + }, 'settings.rest.connect_manual' => static function ( ContainerInterface $container ) : ConnectManualRestEndpoint { return new ConnectManualRestEndpoint( $container->get( 'api.paypal-host-production' ), $container->get( 'api.paypal-host-sandbox' ), - $container->get( 'woocommerce.logger.woocommerce' ) + $container->get( 'woocommerce.logger.woocommerce' ), + $container->get( 'settings.data.general' ) + ); + }, + '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 { @@ -110,6 +131,35 @@ return array( return in_array( $country, $eligible_countries, true ); }, + 'settings.service.signup-link-cache' => static function ( ContainerInterface $container ) : Cache { + return new Cache( 'ppcp-paypal-signup-link' ); + }, + 'settings.service.connection-url-generators' => static function ( ContainerInterface $container ) : array { + // Define available environments. + $environments = array( + 'production' => array( + 'partner_referrals' => $container->get( 'api.endpoint.partner-referrals-production' ), + ), + 'sandbox' => array( + 'partner_referrals' => $container->get( 'api.endpoint.partner-referrals-sandbox' ), + ), + ); + + $generators = array(); + + // Instantiate URL generators for each environment. + foreach ( $environments as $environment => $config ) { + $generators[ $environment ] = new ConnectionUrlGenerator( + $config['partner_referrals'], + $container->get( 'api.repository.partner-referrals-data' ), + $container->get( 'settings.service.signup-link-cache' ), + $environment, + $container->get( 'woocommerce.logger.woocommerce' ) + ); + } + + return $generators; + }, 'settings.switch-ui.endpoint' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint { return new SwitchSettingsUiEndpoint( $container->get( 'woocommerce.logger.woocommerce' ), diff --git a/modules/ppcp-settings/src/Data/CommonSettings.php b/modules/ppcp-settings/src/Data/CommonSettings.php new file mode 100644 index 000000000..8f7dd1ddf --- /dev/null +++ b/modules/ppcp-settings/src/Data/CommonSettings.php @@ -0,0 +1,119 @@ + 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 ); + } +} diff --git a/modules/ppcp-settings/src/Data/OnboardingProfile.php b/modules/ppcp-settings/src/Data/OnboardingProfile.php index e1f9e16b4..b04d7879f 100644 --- a/modules/ppcp-settings/src/Data/OnboardingProfile.php +++ b/modules/ppcp-settings/src/Data/OnboardingProfile.php @@ -64,14 +64,11 @@ class OnboardingProfile extends AbstractDataModel { */ protected function get_defaults() : array { return array( - 'completed' => false, - 'step' => 0, - 'use_sandbox' => false, - 'use_manual_connection' => false, - 'client_id' => '', - 'client_secret' => '', - 'is_casual_seller' => null, - 'products' => array(), + 'completed' => false, + 'step' => 0, + 'is_casual_seller' => null, + 'are_optional_payment_methods_enabled' => true, + 'products' => array(), ); } @@ -113,78 +110,6 @@ class OnboardingProfile extends AbstractDataModel { $this->data['step'] = $step; } - /** - * Gets the 'use sandbox' setting. - * - * @return bool - */ - public function get_sandbox() : bool { - return (bool) $this->data['use_sandbox']; - } - - /** - * Sets the 'use sandbox' setting. - * - * @param bool $use_sandbox Whether to use sandbox mode. - */ - public function set_sandbox( bool $use_sandbox ) : void { - $this->data['use_sandbox'] = $use_sandbox; - } - - /** - * Gets the 'use manual connection' setting. - * - * @return bool - */ - public function get_manual_connection() : bool { - return (bool) $this->data['use_manual_connection']; - } - - /** - * Sets the 'use manual connection' setting. - * - * @param bool $use_manual_connection Whether to use manual connection. - */ - public function set_manual_connection( bool $use_manual_connection ) : void { - $this->data['use_manual_connection'] = $use_manual_connection; - } - - /** - * Gets the client ID. - * - * @return string - */ - public function get_client_id() : string { - return $this->data['client_id']; - } - - /** - * Sets the client ID. - * - * @param string $client_id The client ID. - */ - public function set_client_id( string $client_id ) : void { - $this->data['client_id'] = sanitize_text_field( $client_id ); - } - - /** - * Gets the client secret. - * - * @return string - */ - public function get_client_secret() : string { - return $this->data['client_secret']; - } - - /** - * Sets the client secret. - * - * @param string $client_secret The client secret. - */ - public function set_client_secret( string $client_secret ) : void { - $this->data['client_secret'] = sanitize_text_field( $client_secret ); - } - /** * Gets the casual seller flag. * @@ -203,6 +128,15 @@ class OnboardingProfile extends AbstractDataModel { $this->data['is_casual_seller'] = $casual_seller; } + /** + * Sets the optional payment methods flag. + * + * @param bool|null $are_optional_payment_methods_enabled Whether the PayPal optional payment methods are enabled. + */ + public function set_are_optional_payment_methods_enabled( ?bool $are_optional_payment_methods_enabled ) : void { + $this->data['are_optional_payment_methods_enabled'] = $are_optional_payment_methods_enabled; + } + /** * Gets the active product types for this store. * diff --git a/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php new file mode 100644 index 000000000..c7345148e --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php @@ -0,0 +1,133 @@ + 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(); + } +} diff --git a/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php index af81b90ca..7046342a2 100644 --- a/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php @@ -10,17 +10,16 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings\Endpoint; use Exception; -use Psr\Log\LoggerInterface; -use RuntimeException; use stdClass; +use RuntimeException; +use Psr\Log\LoggerInterface; +use WP_REST_Request; +use WP_REST_Response; +use WP_REST_Server; use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Orders; use WooCommerce\PayPalCommerce\ApiClient\Helper\InMemoryCache; -use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; -use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException; -use WP_REST_Server; -use WP_REST_Response; -use WP_REST_Request; +use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings; /** * REST controller for connection via manual credentials input. @@ -55,6 +54,13 @@ class ConnectManualRestEndpoint extends RestEndpoint { */ protected $rest_base = 'connect_manual'; + /** + * Settings instance. + * + * @var GeneralSettings + */ + private $settings = null; + /** * Field mapping for request. * @@ -78,19 +84,21 @@ class ConnectManualRestEndpoint extends RestEndpoint { /** * ConnectManualRestEndpoint constructor. * - * @param string $live_host The API host for the live mode. + * @param string $live_host The API host for the live mode. * @param string $sandbox_host The API host for the sandbox mode. - * @param LoggerInterface $logger The logger. + * @param LoggerInterface $logger The logger. + * @param GeneralSettings $settings Settings instance. */ public function __construct( string $live_host, string $sandbox_host, - LoggerInterface $logger + LoggerInterface $logger, + GeneralSettings $settings ) { - $this->live_host = $live_host; $this->sandbox_host = $sandbox_host; $this->logger = $logger; + $this->settings = $settings; } /** @@ -126,47 +134,52 @@ class ConnectManualRestEndpoint extends RestEndpoint { $use_sandbox = (bool) ( $data['use_sandbox'] ?? false ); if ( empty( $client_id ) || empty( $client_secret ) ) { - return rest_ensure_response( - array( - 'success' => false, - 'message' => 'No client ID or secret provided.', - ) - ); + return $this->return_error( 'No client ID or secret provided.' ); } try { $payee = $this->request_payee( $client_id, $client_secret, $use_sandbox ); } catch ( Exception $exception ) { - return rest_ensure_response( - array( - 'success' => false, - 'message' => $exception->getMessage(), - ) - ); - + return $this->return_error( $exception->getMessage() ); } - $result = array( - 'merchantId' => $payee->merchant_id, - 'email' => $payee->email_address, - 'success' => true, - ); + if ( $use_sandbox ) { + $this->settings->set_is_sandbox( true ); + $this->settings->set_sandbox_client_id( $client_id ); + $this->settings->set_sandbox_client_secret( $client_secret ); + $this->settings->set_sandbox_merchant_id( $payee->merchant_id ); + $this->settings->set_sandbox_merchant_email( $payee->email_address ); + } else { + $this->settings->set_is_sandbox( false ); + $this->settings->set_live_client_id( $client_id ); + $this->settings->set_live_client_secret( $client_secret ); + $this->settings->set_live_merchant_id( $payee->merchant_id ); + $this->settings->set_live_merchant_email( $payee->email_address ); + } + $this->settings->save(); - return rest_ensure_response( $result ); + return $this->return_success( + array( + 'merchantId' => $payee->merchant_id, + 'email' => $payee->email_address, + ) + ); } /** * Retrieves the payee object with the merchant data * by creating a minimal PayPal order. * - * @param string $client_id The client ID. - * @param string $client_secret The client secret. - * @param bool $use_sandbox Whether to use the sandbox mode. - * @return stdClass The payee object. * @throws Exception When failed to retrieve payee. * * phpcs:disable Squiz.Commenting * phpcs:disable Generic.Commenting + * + * @param string $client_secret The client secret. + * @param bool $use_sandbox Whether to use the sandbox mode. + * @param string $client_id The client ID. + * + * @return stdClass The payee object. */ private function request_payee( string $client_id, @@ -176,24 +189,13 @@ class ConnectManualRestEndpoint extends RestEndpoint { $host = $use_sandbox ? $this->sandbox_host : $this->live_host; - $empty_settings = new class() implements ContainerInterface - { - public function get( string $id ) { - throw new NotFoundException(); - } - - public function has( string $id ) { - return false; - } - }; - $bearer = new PayPalBearer( new InMemoryCache(), $host, $client_id, $client_secret, $this->logger, - $empty_settings + null ); $orders = new Orders( diff --git a/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php new file mode 100644 index 000000000..8ed204383 --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php @@ -0,0 +1,105 @@ +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() ); + } + } +} diff --git a/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php index 6c59b1622..0fbb9fcf9 100644 --- a/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php @@ -41,35 +41,23 @@ class OnboardingRestEndpoint extends RestEndpoint { * @var array */ private array $field_map = array( - 'completed' => array( + 'completed' => array( 'js_name' => 'completed', 'sanitize' => 'to_boolean', ), - 'step' => array( + 'step' => array( 'js_name' => 'step', 'sanitize' => 'to_number', ), - 'use_sandbox' => array( - 'js_name' => 'useSandbox', - 'sanitize' => 'to_boolean', - ), - 'use_manual_connection' => array( - 'js_name' => 'useManualConnection', - 'sanitize' => 'to_boolean', - ), - 'client_id' => array( - 'js_name' => 'clientId', - 'sanitize' => 'sanitize_text_field', - ), - 'client_secret' => array( - 'js_name' => 'clientSecret', - 'sanitize' => 'sanitize_text_field', - ), - 'is_casual_seller' => array( + 'is_casual_seller' => array( 'js_name' => 'isCasualSeller', 'sanitize' => 'to_boolean', ), - 'products' => array( + 'are_optional_payment_methods_enabled' => array( + 'js_name' => 'areOptionalPaymentMethodsEnabled', + 'sanitize' => 'to_boolean', + ), + 'products' => array( 'js_name' => 'products', ), ); @@ -147,9 +135,9 @@ class OnboardingRestEndpoint extends RestEndpoint { $this->flag_map ); - return rest_ensure_response( + return $this->return_success( + $js_data, array( - 'data' => $js_data, 'flags' => $js_flags, ) ); diff --git a/modules/ppcp-settings/src/Endpoint/RestEndpoint.php b/modules/ppcp-settings/src/Endpoint/RestEndpoint.php index 08191276b..76626ac0c 100644 --- a/modules/ppcp-settings/src/Endpoint/RestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/RestEndpoint.php @@ -10,14 +10,12 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings\Endpoint; use WC_REST_Controller; +use WP_REST_Response; /** * Base class for REST controllers in the settings module. - * - * This is a base class for specific REST endpoints; do not instantiate this - * class directly. */ -class RestEndpoint extends WC_REST_Controller { +abstract class RestEndpoint extends WC_REST_Controller { /** * Endpoint namespace. * @@ -34,6 +32,54 @@ class RestEndpoint extends WC_REST_Controller { return current_user_can( 'manage_woocommerce' ); } + /** + * Returns a successful REST API response. + * + * @param mixed $data The main response data. + * @param array $extra Optional, additional response data. + * + * @return WP_REST_Response The successful response. + */ + protected function return_success( $data, array $extra = array() ) : WP_REST_Response { + $response = array( + 'success' => true, + 'data' => $data, + ); + + if ( $extra ) { + foreach ( $extra as $key => $value ) { + if ( isset( $response[ $key ] ) ) { + continue; + } + + $response[ $key ] = $value; + } + } + + return rest_ensure_response( $response ); + } + + /** + * Returns an error REST API response. + * + * @param string $reason The reason for the error. + * @param mixed $details Optional details about the error. + * + * @return WP_REST_Response The error response. + */ + protected function return_error( string $reason, $details = null ) : WP_REST_Response { + $response = array( + 'success' => false, + 'message' => $reason, + ); + + if ( ! is_null( $details ) ) { + $response['details'] = $details; + } + + return rest_ensure_response( $response ); + } + /** * Sanitizes parameters based on a field mapping. * diff --git a/modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php b/modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php index ebc85d9dc..244c26dfe 100644 --- a/modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php @@ -15,6 +15,8 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData; /** * Class SwitchSettingsUiEndpoint + * + * Note: This is an ajax handler, not a REST endpoint */ class SwitchSettingsUiEndpoint { diff --git a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php new file mode 100644 index 000000000..6e91aba3a --- /dev/null +++ b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php @@ -0,0 +1,227 @@ +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(); + } +} diff --git a/modules/ppcp-settings/src/SettingsModule.php b/modules/ppcp-settings/src/SettingsModule.php index f0c3770f3..7c9dca2f8 100644 --- a/modules/ppcp-settings/src/SettingsModule.php +++ b/modules/ppcp-settings/src/SettingsModule.php @@ -9,8 +9,7 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings; -use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint; -use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint; +use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; @@ -26,7 +25,7 @@ class SettingsModule implements ServiceModule, ExecutableModule { /** * Returns whether the old settings UI should be loaded. */ - public static function should_use_the_old_ui(): bool { + public static function should_use_the_old_ui() : bool { return apply_filters( 'woocommerce_paypal_payments_should_use_the_old_ui', (bool) get_option( SwitchSettingsUiEndpoint::OPTION_NAME_SHOULD_USE_OLD_UI ) === true @@ -89,7 +88,13 @@ class SettingsModule implements ServiceModule, ExecutableModule { $endpoint = $container->get( 'settings.switch-ui.endpoint' ); assert( $endpoint instanceof SwitchSettingsUiEndpoint ); - add_action( 'wc_ajax_' . SwitchSettingsUiEndpoint::ENDPOINT, array( $endpoint, 'handle_request' ) ); + add_action( + 'wc_ajax_' . SwitchSettingsUiEndpoint::ENDPOINT, + array( + $endpoint, + 'handle_request', + ) + ); return true; } @@ -170,13 +175,17 @@ class SettingsModule implements ServiceModule, ExecutableModule { add_action( 'rest_api_init', static function () use ( $container ) : void { - $onboarding_endpoint = $container->get( 'settings.rest.onboarding' ); - assert( $onboarding_endpoint instanceof OnboardingRestEndpoint ); - $onboarding_endpoint->register_routes(); + $endpoints = array( + $container->get( 'settings.rest.onboarding' ), + $container->get( 'settings.rest.common' ), + $container->get( 'settings.rest.connect_manual' ), + $container->get( 'settings.rest.login_link' ), + ); - $connect_manual_endpoint = $container->get( 'settings.rest.connect_manual' ); - assert( $connect_manual_endpoint instanceof ConnectManualRestEndpoint ); - $connect_manual_endpoint->register_routes(); + foreach ( $endpoints as $endpoint ) { + assert( $endpoint instanceof RestEndpoint ); + $endpoint->register_routes(); + } } ); diff --git a/yarn.lock b/yarn.lock index 8d605e524..f808c687c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2671,9 +2671,9 @@ mime "^3.0.0" web-vitals "^4.2.1" -"@wordpress/element@^6.1.0": +"@wordpress/element@*", "@wordpress/element@^6.1.0": version "6.11.0" - resolved "https://registry.npmjs.org/@wordpress/element/-/element-6.11.0.tgz" + resolved "https://registry.yarnpkg.com/@wordpress/element/-/element-6.11.0.tgz#7bc3e453a95bb806a707b4dc617373afa108af19" integrity sha512-UvHFYkT+EEaXEyEfw+iqLHRO9OwBjjsUydEMHcqntzkNcsYeAbmaL9V8R9ikXHLe6ftdbkwoXgF85xVPhVsL+Q== dependencies: "@babel/runtime" "7.25.7" @@ -2715,6 +2715,15 @@ globals "^13.12.0" requireindex "^1.2.0" +"@wordpress/icons@^10.11.0": + version "10.11.0" + resolved "https://registry.yarnpkg.com/@wordpress/icons/-/icons-10.11.0.tgz#0beedef8ee49c135412fb81fc59440dd48d652aa" + integrity sha512-RMetpFwUIeh3sVj2+p6+QX5AW8pF7DvQzxH9jUr8YjaF2iLE64vy6m0cZz/X8xkSktHrXMuPJIr7YIVF20TEyw== + dependencies: + "@babel/runtime" "7.25.7" + "@wordpress/element" "*" + "@wordpress/primitives" "*" + "@wordpress/jest-console@*": version "8.11.0" resolved "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.11.0.tgz" @@ -2749,6 +2758,15 @@ resolved "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.11.0.tgz" integrity sha512-Aoc8+xWOyiXekodjaEjS44z85XK877LzHZqsQuhC0kNgneDLrKkwI5qNgzwzAMbJ9jI58MPqVISCOX0bDLUPbw== +"@wordpress/primitives@*": + version "4.11.0" + resolved "https://registry.yarnpkg.com/@wordpress/primitives/-/primitives-4.11.0.tgz#7bc24c07ed11057340832791c1c21e75a5181194" + integrity sha512-CoBXbh0mOSxcZtuzL7gK3RVumFx71DXQBfd3IkbRHuuVxa+2hI4KDuFyomSsbjQDshHsfuVrKUvuT3UGt6pdpQ== + dependencies: + "@babel/runtime" "7.25.7" + "@wordpress/element" "*" + clsx "^2.1.1" + "@wordpress/scripts@~30.0.0": version "30.0.6" resolved "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.0.6.tgz" @@ -3727,6 +3745,11 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + co@^4.6.0: version "4.6.0" resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"