diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 041455441..9fc8d4a29 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -27,7 +27,7 @@ web_environment: - ADMIN_USER=admin - ADMIN_PASS=admin - ADMIN_EMAIL=admin@example.com - - WC_VERSION=7.7.2 + - WC_VERSION=9.5.1 # Key features of ddev's config.yaml: diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index e7957d210..c3e07d1bb 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -50,7 +50,7 @@ jobs: if: github.event.inputs.filePrefix - name: Upload - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ env.FILENAME }} path: dist/ diff --git a/changelog.txt b/changelog.txt index b0ffc609c..2e1d3420c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,22 +1,34 @@ *** Changelog *** += 2.9.6 - 2025-01-06 = +* Fix - NOT_ENABLED_TO_VAULT_PAYMENT_SOURCE on PayPal transactions when using ACDC Vaulting without PayPal Vault approval #2955 +* Fix - Express buttons for Free Trial Subscription products on Block Cart/Checkout trigger CANNOT_BE_ZERO_OR_NEGATIVE error #2872 +* Fix - String translations not applied to Card Fields on Block Checkout #2934 +* Fix - Fastlane component included in script when Fastlane is disabled #2911 +* Fix - Zero amount line items may trigger CANNOT_BE_ZERO_OR_NEGATIVE error after rounding error #2906 +* Fix - “Save changes” is grey and unclickable when switching from Sandbox to Live #2895 +* Fix - plugin queries variations when button/messaging is disabled on single product page #2896 +* Fix - Use get_id instead of get_order_number on setting custom_id (author @0verscore) #2930 +* Enhancement - Improve fraud response order notes for Advanced Card Processing transactions #2905 +* Tweak - Update the minimum plugin requirements to WordPress 6.5 & WooCommerce 9.2 #2920 + = 2.9.5 - 2024-12-10 = -Fix - Early translation loading triggers `Function _load_textdomain_just_in_time was called incorrectly.` notice #2816 -Fix - ACDC card fields not loading and payment not successful when Classic Checkout Smart Button Location disabled #2852 -Fix - ACDC gateway does not appear for guests when is Fastlane enabled and a subscription product is in the cart #2745 -Fix - "Voide authorization" button does not appear for Apple Pay/Google Pay orders when payment buttons are separated #2752 -Fix - Additional payment tokens saved with new customer_id #2820 -Fix - Vaulted payment method may not be displayed in PayPal button for return buyer #2809 -Fix - Conflict with EasyShip plugin due to shipping methods loading too early #2845 -Fix - Restore accidentally removed ACDC currencies #2838 -Enhancement - Native gateway icon for PayPal & Pay upon Invoice gateways #2712 -Enhancement - Allow disabling specific card types for Fastlane #2704 -Enhancement - Fastlane Insights SDK implementation for block Checkout #2737 -Enhancement - Hide split local APMs in Payments settings tab when PayPal is not enabled #2703 -Enhancement - Do not load split local APMs on Checkout when PayPal is not enabled #2792 -Enhancement - Add support for Button Options in the Block Checkout for Apple Pay & Google Pay buttons #2797 #2772 -Enhancement - Disable “Add payment method” button while saving ACDC payment #2794 -Enhancement - Sanitize soft_descriptor field #2846 #2854 +* Fix - Early translation loading triggers `Function _load_textdomain_just_in_time was called incorrectly.` notice #2816 +* Fix - ACDC card fields not loading and payment not successful when Classic Checkout Smart Button Location disabled #2852 +* Fix - ACDC gateway does not appear for guests when is Fastlane enabled and a subscription product is in the cart #2745 +* Fix - "Voide authorization" button does not appear for Apple Pay/Google Pay orders when payment buttons are separated #2752 +* Fix - Additional payment tokens saved with new customer_id #2820 +* Fix - Vaulted payment method may not be displayed in PayPal button for return buyer #2809 +* Fix - Conflict with EasyShip plugin due to shipping methods loading too early #2845 +* Fix - Restore accidentally removed ACDC currencies #2838 +* Enhancement - Native gateway icon for PayPal & Pay upon Invoice gateways #2712 +* Enhancement - Allow disabling specific card types for Fastlane #2704 +* Enhancement - Fastlane Insights SDK implementation for block Checkout #2737 +* Enhancement - Hide split local APMs in Payments settings tab when PayPal is not enabled #2703 +* Enhancement - Do not load split local APMs on Checkout when PayPal is not enabled #2792 +* Enhancement - Add support for Button Options in the Block Checkout for Apple Pay & Google Pay buttons #2797 #2772 +* Enhancement - Disable “Add payment method” button while saving ACDC payment #2794 +* Enhancement - Sanitize soft_descriptor field #2846 #2854 = 2.9.4 - 2024-11-11 = * Fix - Apple Pay button preview missing in Standard payment and Advanced Processing tabs #2755 diff --git a/modules/ppcp-api-client/services.php b/modules/ppcp-api-client/services.php index 280438b80..b2f489da5 100644 --- a/modules/ppcp-api-client/services.php +++ b/modules/ppcp-api-client/services.php @@ -79,6 +79,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData; use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer; +use WooCommerce\PayPalCommerce\WcGateway\Helper\EnvironmentConfig; return array( 'api.host' => function( ContainerInterface $container ) : string { @@ -115,19 +116,13 @@ return array( return 'WC-'; }, 'api.bearer' => static function ( ContainerInterface $container ): Bearer { - $cache = new Cache( 'ppcp-paypal-bearer' ); - $key = $container->get( 'api.key' ); - $secret = $container->get( 'api.secret' ); - $host = $container->get( 'api.host' ); - $logger = $container->get( 'woocommerce.logger.woocommerce' ); - $settings = $container->get( 'wcgateway.settings' ); return new PayPalBearer( - $cache, - $host, - $key, - $secret, - $logger, - $settings + $container->get( 'api.paypal-bearer-cache' ), + $container->get( 'api.host' ), + $container->get( 'api.key' ), + $container->get( 'api.secret' ), + $container->get( 'woocommerce.logger.woocommerce' ), + $container->get( 'wcgateway.settings' ) ); }, 'api.endpoint.partners' => static function ( ContainerInterface $container ) : PartnersEndpoint { @@ -839,6 +834,9 @@ return array( $container->get( 'wcgateway.settings' ) ); }, + 'api.paypal-bearer-cache' => static function( ContainerInterface $container ): Cache { + return new Cache( 'ppcp-paypal-bearer' ); + }, 'api.client-credentials-cache' => static function( ContainerInterface $container ): Cache { return new Cache( 'ppcp-client-credentials-cache' ); }, @@ -879,4 +877,54 @@ return array( 'api.partner_merchant_id-sandbox' => static function( ContainerInterface $container ) : string { return CONNECT_WOO_SANDBOX_MERCHANT_ID; }, + 'api.endpoint.login-seller-production' => static function ( ContainerInterface $container ) : LoginSeller { + return new LoginSeller( + $container->get( 'api.paypal-host-production' ), + $container->get( 'api.partner_merchant_id-production' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + 'api.endpoint.login-seller-sandbox' => static function ( ContainerInterface $container ) : LoginSeller { + return new LoginSeller( + $container->get( 'api.paypal-host-sandbox' ), + $container->get( 'api.partner_merchant_id-sandbox' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + 'api.env.paypal-host' => static function ( ContainerInterface $container ) : EnvironmentConfig { + /** + * Environment specific API host names. + * + * @type EnvironmentConfig + */ + return EnvironmentConfig::create( + 'string', + $container->get( 'api.paypal-host-production' ), + $container->get( 'api.paypal-host-sandbox' ) + ); + }, + 'api.env.endpoint.login-seller' => static function ( ContainerInterface $container ) : EnvironmentConfig { + /** + * Environment specific LoginSeller API instances. + * + * @type EnvironmentConfig + */ + return EnvironmentConfig::create( + LoginSeller::class, + $container->get( 'api.endpoint.login-seller-production' ), + $container->get( 'api.endpoint.login-seller-sandbox' ) + ); + }, + 'api.env.endpoint.partner-referrals' => static function ( ContainerInterface $container ) : EnvironmentConfig { + /** + * Environment specific PartnerReferrals API instances. + * + * @type EnvironmentConfig + */ + return EnvironmentConfig::create( + PartnerReferrals::class, + $container->get( 'api.endpoint.partner-referrals-production' ), + $container->get( 'api.endpoint.partner-referrals-sandbox' ) + ); + }, ); diff --git a/modules/ppcp-api-client/src/ApiModule.php b/modules/ppcp-api-client/src/ApiModule.php index c75c63e6b..a2880761c 100644 --- a/modules/ppcp-api-client/src/ApiModule.php +++ b/modules/ppcp-api-client/src/ApiModule.php @@ -20,6 +20,8 @@ use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameI use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; +use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; +use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken; /** * Class ApiModule @@ -103,6 +105,34 @@ class ApiModule implements ServiceModule, ExtendingModule, ExecutableModule { 2 ); + /** + * Flushes the API client caches. + */ + add_action( + 'woocommerce_paypal_payments_flush_api_cache', + static function () use ( $c ) { + $caches = array( + 'api.paypal-bearer-cache' => array( + PayPalBearer::CACHE_KEY, + ), + 'api.client-credentials-cache' => array( + SdkClientToken::CACHE_KEY, + ), + ); + + foreach ( $caches as $cache_id => $keys ) { + $cache = $c->get( $cache_id ); + assert( $cache instanceof Cache ); + + foreach ( $keys as $key ) { + if ( $cache->has( $key ) ) { + $cache->delete( $key ); + } + } + } + } + ); + return true; } } diff --git a/modules/ppcp-api-client/src/Entity/FraudProcessorResponse.php b/modules/ppcp-api-client/src/Entity/FraudProcessorResponse.php index 2cc7c5480..baecabf73 100644 --- a/modules/ppcp-api-client/src/Entity/FraudProcessorResponse.php +++ b/modules/ppcp-api-client/src/Entity/FraudProcessorResponse.php @@ -17,44 +17,44 @@ class FraudProcessorResponse { /** * The AVS response code. * - * @var string|null + * @var string */ - protected $avs_code; + protected string $avs_code; /** * The CVV response code. * - * @var string|null + * @var string */ - protected $cvv_code; + protected string $cvv2_code; /** * FraudProcessorResponse constructor. * * @param string|null $avs_code The AVS response code. - * @param string|null $cvv_code The CVV response code. + * @param string|null $cvv2_code The CVV response code. */ - public function __construct( ?string $avs_code, ?string $cvv_code ) { - $this->avs_code = $avs_code; - $this->cvv_code = $cvv_code; + public function __construct( ?string $avs_code, ?string $cvv2_code ) { + $this->avs_code = (string) $avs_code; + $this->cvv2_code = (string) $cvv2_code; } /** * Returns the AVS response code. * - * @return string|null + * @return string */ - public function avs_code(): ?string { + public function avs_code(): string { return $this->avs_code; } /** * Returns the CVV response code. * - * @return string|null + * @return string */ - public function cvv_code(): ?string { - return $this->cvv_code; + public function cvv_code(): string { + return $this->cvv2_code; } /** @@ -64,11 +64,99 @@ class FraudProcessorResponse { */ public function to_array(): array { return array( - 'avs_code' => $this->avs_code() ?: '', + 'avs_code' => $this->avs_code(), + 'cvv2_code' => $this->cvv_code(), + // For backwards compatibility. 'address_match' => $this->avs_code() === 'M' ? 'Y' : 'N', 'postal_match' => $this->avs_code() === 'M' ? 'Y' : 'N', 'cvv_match' => $this->cvv_code() === 'M' ? 'Y' : 'N', ); } + /** + * Retrieves the AVS (Address Verification System) code messages based on the AVS response code. + * + * Provides human-readable descriptions for various AVS response codes + * and returns the corresponding message for the given code. + * + * @return string The AVS response code message. If the code is not found, an error message is returned. + */ + public function get_avs_code_message(): string { + if ( ! $this->avs_code() ) { + return ''; + } + $messages = array( + /* Visa, Mastercard, Discover, American Express */ + 'A' => 'A: Address - Address only (no ZIP code)', + 'B' => 'B: International "A" - Address only (no ZIP code)', + 'C' => 'C: International "N" - None. The transaction is declined.', + 'D' => 'D: International "X" - Address and Postal Code', + 'E' => 'E: Not allowed for MOTO (Internet/Phone) transactions - Not applicable. The transaction is declined.', + 'F' => 'F: UK-specific "X" - Address and Postal Code', + 'G' => 'G: Global Unavailable - Not applicable', + 'I' => 'I: International Unavailable - Not applicable', + 'M' => 'M: Address - Address and Postal Code', + 'N' => 'N: No - None. The transaction is declined.', + 'P' => 'P: Postal (International "Z") - Postal Code only (no Address)', + 'R' => 'R: Retry - Not applicable', + 'S' => 'S: Service not Supported - Not applicable', + 'U' => 'U: Unavailable / Address not checked, or acquirer had no response. Service not available.', + 'W' => 'W: Whole ZIP - Nine-digit ZIP code (no Address)', + 'X' => 'X: Exact match - Address and nine-digit ZIP code)', + 'Y' => 'Y: Yes - Address and five-digit ZIP', + 'Z' => 'Z: ZIP - Five-digit ZIP code (no Address)', + /* Maestro */ + '0' => '0: All the address information matched.', + '1' => '1: None of the address information matched. The transaction is declined.', + '2' => '2: Part of the address information matched.', + '3' => '3: The merchant did not provide AVS information. Not processed.', + '4' => '4: Address not checked, or acquirer had no response. Service not available.', + ); + + /** + * Psalm suppress + * + * @psalm-suppress PossiblyNullArrayOffset + * @psalm-suppress PossiblyNullArgument + */ + return $messages[ $this->avs_code() ] ?? sprintf( '%s: Error', $this->avs_code() ); + } + + /** + * Retrieves the CVV2 code message based on the CVV code provided. + * + * This method maps CVV response codes to their corresponding descriptive messages. + * + * @return string The descriptive message corresponding to the CVV2 code, or a formatted error message if the code is unrecognized. + */ + public function get_cvv2_code_message(): string { + if ( ! $this->cvv_code() ) { + return ''; + } + $messages = array( + /* Visa, Mastercard, Discover, American Express */ + 'E' => 'E: Error - Unrecognized or Unknown response', + 'I' => 'I: Invalid or Null', + 'M' => 'M: Match or CSC', + 'N' => 'N: No match', + 'P' => 'P: Not processed', + 'S' => 'S: Service not supported', + 'U' => 'U: Unknown - Issuer is not certified', + 'X' => 'X: No response / Service not available', + /* Maestro */ + '0' => '0: Matched CVV2', + '1' => '1: No match', + '2' => '2: The merchant has not implemented CVV2 code handling', + '3' => '3: Merchant has indicated that CVV2 is not present on card', + '4' => '4: Service not available', + ); + + /** + * Psalm suppress + * + * @psalm-suppress PossiblyNullArrayOffset + * @psalm-suppress PossiblyNullArgument + */ + return $messages[ $this->cvv_code() ] ?? sprintf( '%s: Error', $this->cvv_code() ); + } } diff --git a/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php b/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php index 7cb0c048f..1d222f605 100644 --- a/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php +++ b/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php @@ -178,6 +178,11 @@ class PurchaseUnitSanitizer { // Get a more intelligent adjustment mechanism. $increment = ( new MoneyFormatter() )->minimum_increment( $item['unit_amount']['currency_code'] ); + // not floor items that will be negative then. + if ( (float) $item['unit_amount']['value'] < $increment ) { + continue; + } + $this->purchase_unit['items'][ $index ]['unit_amount'] = ( new Money( ( (float) $item['unit_amount']['value'] ) - $increment, $item['unit_amount']['currency_code'] diff --git a/modules/ppcp-applepay/src/ApplepayModule.php b/modules/ppcp-applepay/src/ApplepayModule.php index aa9876069..dc7b3cf11 100644 --- a/modules/ppcp-applepay/src/ApplepayModule.php +++ b/modules/ppcp-applepay/src/ApplepayModule.php @@ -184,21 +184,17 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule add_filter( 'woocommerce_paypal_payments_rest_common_merchant_data', - function( array $merchant_data ) use ( $c ): array { - if ( ! isset( $merchant_data['features'] ) ) { - $merchant_data['features'] = array(); - } - + function( array $features ) use ( $c ): array { $product_status = $c->get( 'applepay.apple-product-status' ); assert( $product_status instanceof AppleProductStatus ); $apple_pay_enabled = $product_status->is_active(); - $merchant_data['features']['apple_pay'] = array( + $features['apple_pay'] = array( 'enabled' => $apple_pay_enabled, ); - return $merchant_data; + return $features; } ); diff --git a/modules/ppcp-axo-block/src/AxoBlockModule.php b/modules/ppcp-axo-block/src/AxoBlockModule.php index c8216bf62..af94976a3 100644 --- a/modules/ppcp-axo-block/src/AxoBlockModule.php +++ b/modules/ppcp-axo-block/src/AxoBlockModule.php @@ -92,7 +92,10 @@ class AxoBlockModule implements ServiceModule, ExtendingModule, ExecutableModule */ add_filter( 'woocommerce_paypal_payments_sdk_components_hook', - function( $components ) { + function( $components ) use ( $c ) { + if ( ! $c->has( 'axo.available' ) || ! $c->get( 'axo.available' ) ) { + return $components; + } $components[] = 'fastlane'; return $components; } diff --git a/modules/ppcp-axo/services.php b/modules/ppcp-axo/services.php index 121b17805..ca427700d 100644 --- a/modules/ppcp-axo/services.php +++ b/modules/ppcp-axo/services.php @@ -44,7 +44,9 @@ return array( // If AXO is configured and onboarded. 'axo.available' => static function ( ContainerInterface $container ): bool { - return true; + $settings = $container->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + return $settings->has( 'axo_enabled' ) && $settings->get( 'axo_enabled' ); }, 'axo.url' => static function ( ContainerInterface $container ): string { diff --git a/modules/ppcp-axo/src/AxoModule.php b/modules/ppcp-axo/src/AxoModule.php index 3ac0ff157..21ee84a35 100644 --- a/modules/ppcp-axo/src/AxoModule.php +++ b/modules/ppcp-axo/src/AxoModule.php @@ -246,7 +246,13 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { */ add_filter( 'woocommerce_paypal_payments_sdk_components_hook', - function( $components ) { + function( $components ) use ( $c ) { + $dcc_configuration = $c->get( 'wcgateway.configuration.dcc' ); + assert( $dcc_configuration instanceof DCCGatewayConfiguration ); + + if ( ! $dcc_configuration->use_fastlane() ) { + return $components; + } $components[] = 'fastlane'; return $components; } @@ -255,14 +261,18 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { add_action( 'wp_head', function () use ( $c ) { - // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript - echo ''; - // Add meta tag to allow feature-detection of the site's AXO payment state. $dcc_configuration = $c->get( 'wcgateway.configuration.dcc' ); assert( $dcc_configuration instanceof DCCGatewayConfiguration ); - $this->add_feature_detection_tag( $dcc_configuration->use_fastlane() ); + if ( $dcc_configuration->use_fastlane() ) { + // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript + echo ''; + + $this->add_feature_detection_tag( true ); + } else { + $this->add_feature_detection_tag( false ); + } } ); diff --git a/modules/ppcp-blocks/resources/js/Components/block-editor-paypal.js b/modules/ppcp-blocks/resources/js/Components/block-editor-paypal.js new file mode 100644 index 000000000..37d99539c --- /dev/null +++ b/modules/ppcp-blocks/resources/js/Components/block-editor-paypal.js @@ -0,0 +1,52 @@ +import { useMemo } from '@wordpress/element'; +import { normalizeStyleForFundingSource } from '../../../../ppcp-button/resources/js/modules/Helper/Style'; +import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js'; + +export const BlockEditorPayPalComponent = ( { + config, + fundingSource, + buttonAttributes, +} ) => { + const urlParams = useMemo( + () => ( { + clientId: 'test', + ...config.scriptData.url_params, + dataNamespace: 'ppcp-blocks-editor-paypal-buttons', + components: 'buttons', + } ), + [] + ); + + const style = useMemo( () => { + const configStyle = normalizeStyleForFundingSource( + config.scriptData.button.style, + fundingSource + ); + + if ( buttonAttributes ) { + return { + ...configStyle, + height: buttonAttributes.height + ? Number( buttonAttributes.height ) + : configStyle.height, + borderRadius: buttonAttributes.borderRadius + ? Number( buttonAttributes.borderRadius ) + : configStyle.borderRadius, + }; + } + + return configStyle; + }, [ fundingSource, buttonAttributes ] ); + + return ( + + false } + /> + + ); +}; diff --git a/modules/ppcp-blocks/resources/js/Components/card-fields.js b/modules/ppcp-blocks/resources/js/Components/card-fields.js index f47b18f35..30de01bb1 100644 --- a/modules/ppcp-blocks/resources/js/Components/card-fields.js +++ b/modules/ppcp-blocks/resources/js/Components/card-fields.js @@ -3,10 +3,10 @@ import { useEffect, useState } from '@wordpress/element'; import { PayPalScriptProvider, PayPalCardFieldsProvider, - PayPalNameField, - PayPalNumberField, - PayPalExpiryField, - PayPalCVVField, + PayPalNameField, + PayPalNumberField, + PayPalExpiryField, + PayPalCVVField, } from '@paypal/react-paypal-js'; import { CheckoutHandler } from './checkout-handler'; @@ -19,11 +19,7 @@ import { import { cartHasSubscriptionProducts } from '../Helper/Subscription'; import { __ } from '@wordpress/i18n'; -export function CardFields( { - config, - eventRegistration, - emitResponse, -} ) { +export function CardFields( { config, eventRegistration, emitResponse } ) { const { onPaymentSetup } = eventRegistration; const { responseTypes } = emitResponse; @@ -96,16 +92,36 @@ export function CardFields( { console.error( err ); } } > - - -
-
- -
-
- -
-
+ + +
+
+ +
+
+ +
+
{ + const { PaymentMethodIcons } = components; + + return ( + <> + + + + ); +}; diff --git a/modules/ppcp-blocks/resources/js/Components/paypal.js b/modules/ppcp-blocks/resources/js/Components/paypal.js new file mode 100644 index 000000000..c9bb1ad88 --- /dev/null +++ b/modules/ppcp-blocks/resources/js/Components/paypal.js @@ -0,0 +1,493 @@ +import { useEffect, useState } from '@wordpress/element'; +import { loadPayPalScript } from '../../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading'; +import { + mergeWcAddress, + paypalAddressToWc, + paypalOrderToWcAddresses, +} from '../Helper/Address'; +import { convertKeysToSnakeCase } from '../Helper/Helper'; +import buttonModuleWatcher from '../../../../ppcp-button/resources/js/modules/ButtonModuleWatcher'; +import { normalizeStyleForFundingSource } from '../../../../ppcp-button/resources/js/modules/Helper/Style'; +import { + cartHasSubscriptionProducts, + isPayPalSubscription, +} from '../Helper/Subscription'; +import { + createOrder, + createSubscription, + createVaultSetupToken, + handleApprove, + handleApproveSubscription, + onApproveSavePayment, +} from '../paypal-config'; + +const PAYPAL_GATEWAY_ID = 'ppcp-gateway'; + +const namespace = 'ppcpBlocksPaypalExpressButtons'; +let registeredContext = false; +let paypalScriptPromise = null; + +export const PayPalComponent = ( { + config, + onClick, + onClose, + onSubmit, + onError, + eventRegistration, + emitResponse, + activePaymentMethod, + shippingData, + isEditing, + fundingSource, + buttonAttributes, +} ) => { + const { onPaymentSetup, onCheckoutFail, onCheckoutValidation } = + eventRegistration; + const { responseTypes } = emitResponse; + + const [ paypalOrder, setPaypalOrder ] = useState( null ); + const [ continuationFilled, setContinuationFilled ] = useState( false ); + const [ gotoContinuationOnError, setGotoContinuationOnError ] = + useState( false ); + + const [ paypalScriptLoaded, setPaypalScriptLoaded ] = useState( false ); + + if ( ! paypalScriptLoaded ) { + if ( ! paypalScriptPromise ) { + // for editor, since canMakePayment was not called + paypalScriptPromise = loadPayPalScript( + namespace, + config.scriptData + ); + } + paypalScriptPromise.then( () => setPaypalScriptLoaded( true ) ); + } + + const methodId = fundingSource + ? `${ config.id }-${ fundingSource }` + : config.id; + + /** + * The block cart displays express checkout buttons. Those buttons are handled by the + * PAYPAL_GATEWAY_ID method on the server ("PayPal Smart Buttons"). + * + * A possible bug in WooCommerce does not use the correct payment method ID for the express + * payment buttons inside the cart, but sends the ID of the _first_ active payment method. + * + * This function uses an internal WooCommerce dispatcher method to set the correct method ID. + */ + const enforcePaymentMethodForCart = () => { + // Do nothing, unless we're handling block cart express payment buttons. + if ( 'cart-block' !== config.scriptData.context ) { + return; + } + + // Set the active payment method to PAYPAL_GATEWAY_ID. + wp.data + .dispatch( 'wc/store/payment' ) + .__internalSetActivePaymentMethod( PAYPAL_GATEWAY_ID, {} ); + }; + + useEffect( () => { + // fill the form if in continuation (for product or mini-cart buttons) + if ( continuationFilled || ! config.scriptData.continuation?.order ) { + return; + } + + try { + const paypalAddresses = paypalOrderToWcAddresses( + config.scriptData.continuation.order + ); + const wcAddresses = wp.data + .select( 'wc/store/cart' ) + .getCustomerData(); + const addresses = mergeWcAddress( wcAddresses, paypalAddresses ); + + wp.data + .dispatch( 'wc/store/cart' ) + .setBillingAddress( addresses.billingAddress ); + + if ( shippingData.needsShipping ) { + wp.data + .dispatch( 'wc/store/cart' ) + .setShippingAddress( addresses.shippingAddress ); + } + } catch ( err ) { + // sometimes the PayPal address is missing, skip in this case. + console.error( err ); + } + + // this useEffect should run only once, but adding this in case of some kind of full re-rendering + setContinuationFilled( true ); + }, [ shippingData, continuationFilled ] ); + + const getCheckoutRedirectUrl = () => { + const checkoutUrl = new URL( config.scriptData.redirect ); + // sometimes some browsers may load some kind of cached version of the page, + // so adding a parameter to avoid that + checkoutUrl.searchParams.append( + 'ppcp-continuation-redirect', + new Date().getTime().toString() + ); + return checkoutUrl.toString(); + }; + + useEffect( () => { + const unsubscribe = onCheckoutValidation( () => { + if ( config.scriptData.continuation ) { + return true; + } + if ( + gotoContinuationOnError && + wp.data.select( 'wc/store/validation' ).hasValidationErrors() + ) { + location.href = getCheckoutRedirectUrl(); + return { type: responseTypes.ERROR }; + } + + return true; + } ); + return unsubscribe; + }, [ onCheckoutValidation, gotoContinuationOnError ] ); + + const handleClick = ( data, actions ) => { + if ( isEditing ) { + return actions.reject(); + } + + window.ppcpFundingSource = data.fundingSource; + + onClick(); + }; + + const shouldHandleShippingInPayPal = () => { + return shouldskipFinalConfirmation() && config.needShipping; + }; + + const shouldskipFinalConfirmation = () => { + if ( config.finalReviewEnabled ) { + return false; + } + + return ( + window.ppcpFundingSource !== 'venmo' || + ! config.scriptData.vaultingEnabled + ); + }; + + let handleShippingOptionsChange = null; + let handleShippingAddressChange = null; + + if ( shippingData.needsShipping && shouldHandleShippingInPayPal() ) { + handleShippingOptionsChange = async ( data, actions ) => { + try { + const shippingOptionId = data.selectedShippingOption?.id; + if ( shippingOptionId ) { + await wp.data + .dispatch( 'wc/store/cart' ) + .selectShippingRate( shippingOptionId ); + await shippingData.setSelectedRates( shippingOptionId ); + } + + const res = await fetch( config.ajax.update_shipping.endpoint, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( { + nonce: config.ajax.update_shipping.nonce, + order_id: data.orderID, + } ), + } ); + + const json = await res.json(); + + if ( ! json.success ) { + throw new Error( json.data.message ); + } + } catch ( e ) { + console.error( e ); + + actions.reject(); + } + }; + + handleShippingAddressChange = async ( data, actions ) => { + try { + const address = paypalAddressToWc( + convertKeysToSnakeCase( data.shippingAddress ) + ); + + await wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { + shipping_address: address, + } ); + + await shippingData.setShippingAddress( address ); + + const res = await fetch( config.ajax.update_shipping.endpoint, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( { + nonce: config.ajax.update_shipping.nonce, + order_id: data.orderID, + } ), + } ); + + const json = await res.json(); + + if ( ! json.success ) { + throw new Error( json.data.message ); + } + } catch ( e ) { + console.error( e ); + + actions.reject(); + } + }; + } + + useEffect( () => { + if ( activePaymentMethod !== methodId ) { + return; + } + + const unsubscribeProcessing = onPaymentSetup( () => { + if ( + cartHasSubscriptionProducts( config.scriptData ) && + config.scriptData.is_free_trial_cart + ) { + return { + type: responseTypes.SUCCESS, + }; + } + + if ( config.scriptData.continuation ) { + return { + type: responseTypes.SUCCESS, + meta: { + paymentMethodData: { + paypal_order_id: + config.scriptData.continuation.order_id, + funding_source: + window.ppcpFundingSource ?? 'paypal', + }, + }, + }; + } + + const addresses = paypalOrderToWcAddresses( paypalOrder ); + + return { + type: responseTypes.SUCCESS, + meta: { + paymentMethodData: { + paypal_order_id: paypalOrder.id, + funding_source: window.ppcpFundingSource ?? 'paypal', + }, + ...addresses, + }, + }; + } ); + return () => { + unsubscribeProcessing(); + }; + }, [ onPaymentSetup, paypalOrder, activePaymentMethod ] ); + + useEffect( () => { + if ( activePaymentMethod !== methodId ) { + return; + } + const unsubscribe = onCheckoutFail( ( { processingResponse } ) => { + console.error( processingResponse ); + if ( onClose ) { + onClose(); + } + if ( config.scriptData.continuation ) { + return true; + } + if ( shouldskipFinalConfirmation() ) { + location.href = getCheckoutRedirectUrl(); + } + return true; + } ); + return unsubscribe; + }, [ onCheckoutFail, onClose, activePaymentMethod ] ); + + if ( config.scriptData.continuation ) { + return ( +
+ ); + } + + if ( ! registeredContext ) { + buttonModuleWatcher.registerContextBootstrap( + config.scriptData.context, + { + createOrder: ( data ) => { + return createOrder( data, config, onError, onClose ); + }, + onApprove: ( data, actions ) => { + return handleApprove( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose + ); + }, + } + ); + registeredContext = true; + } + + const style = normalizeStyleForFundingSource( + config.scriptData.button.style, + fundingSource + ); + + if ( typeof buttonAttributes !== 'undefined' ) { + style.height = buttonAttributes?.height + ? Number( buttonAttributes.height ) + : style.height; + style.borderRadius = buttonAttributes?.borderRadius + ? Number( buttonAttributes.borderRadius ) + : style.borderRadius; + } + + if ( ! paypalScriptLoaded ) { + return null; + } + + const PayPalButton = ppcpBlocksPaypalExpressButtons.Buttons.driver( + 'react', + { React, ReactDOM } + ); + + const getOnShippingOptionsChange = ( fundingSource ) => { + if ( fundingSource === 'venmo' ) { + return null; + } + + return ( data, actions ) => { + shouldHandleShippingInPayPal() + ? handleShippingOptionsChange( data, actions ) + : null; + }; + }; + + const getOnShippingAddressChange = ( fundingSource ) => { + if ( fundingSource === 'venmo' ) { + return null; + } + + return ( data, actions ) => { + const shippingAddressChange = shouldHandleShippingInPayPal() + ? handleShippingAddressChange( data, actions ) + : null; + + return shippingAddressChange; + }; + }; + + if ( + cartHasSubscriptionProducts( config.scriptData ) && + config.scriptData.is_free_trial_cart + ) { + return ( + createVaultSetupToken( config ) } + onApprove={ ( { vaultSetupToken } ) => + onApproveSavePayment( vaultSetupToken, config, onSubmit ) + } + /> + ); + } + + if ( isPayPalSubscription( config.scriptData ) ) { + return ( + + createSubscription( data, actions, config ) + } + onApprove={ ( data, actions ) => + handleApproveSubscription( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose + ) + } + onShippingOptionsChange={ getOnShippingOptionsChange( + fundingSource + ) } + onShippingAddressChange={ getOnShippingAddressChange( + fundingSource + ) } + /> + ); + } + + return ( + + createOrder( data, config, onError, onClose ) + } + onApprove={ ( data, actions ) => + handleApprove( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose + ) + } + onShippingOptionsChange={ getOnShippingOptionsChange( + fundingSource + ) } + onShippingAddressChange={ getOnShippingAddressChange( + fundingSource + ) } + /> + ); +}; diff --git a/modules/ppcp-blocks/resources/js/checkout-block.js b/modules/ppcp-blocks/resources/js/checkout-block.js index b0f971dd2..3ddc2cd92 100644 --- a/modules/ppcp-blocks/resources/js/checkout-block.js +++ b/modules/ppcp-blocks/resources/js/checkout-block.js @@ -1,750 +1,26 @@ -import { useEffect, useState, useMemo } from '@wordpress/element'; import { registerExpressPaymentMethod, registerPaymentMethod, } from '@woocommerce/blocks-registry'; import { __ } from '@wordpress/i18n'; -import { - mergeWcAddress, - paypalAddressToWc, - paypalOrderToWcAddresses, - paypalSubscriptionToWcAddresses, -} from './Helper/Address'; -import { convertKeysToSnakeCase } from './Helper/Helper'; import { cartHasSubscriptionProducts, isPayPalSubscription, } from './Helper/Subscription'; import { loadPayPalScript } from '../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading'; -import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js'; -import { normalizeStyleForFundingSource } from '../../../ppcp-button/resources/js/modules/Helper/Style'; -import buttonModuleWatcher from '../../../ppcp-button/resources/js/modules/ButtonModuleWatcher'; import BlockCheckoutMessagesBootstrap from './Bootstrap/BlockCheckoutMessagesBootstrap'; +import { PayPalComponent } from './Components/paypal'; +import { BlockEditorPayPalComponent } from './Components/block-editor-paypal'; +import { PaypalLabel } from './Components/paypal-label'; const namespace = 'ppcpBlocksPaypalExpressButtons'; const config = wc.wcSettings.getSetting( 'ppcp-gateway_data' ); window.ppcpFundingSource = config.fundingSource; -let registeredContext = false; let paypalScriptPromise = null; -const PAYPAL_GATEWAY_ID = 'ppcp-gateway'; - -const PayPalComponent = ( { - onClick, - onClose, - onSubmit, - onError, - eventRegistration, - emitResponse, - activePaymentMethod, - shippingData, - isEditing, - fundingSource, - buttonAttributes, -} ) => { - const { onPaymentSetup, onCheckoutFail, onCheckoutValidation } = - eventRegistration; - const { responseTypes } = emitResponse; - - const [ paypalOrder, setPaypalOrder ] = useState( null ); - const [ continuationFilled, setContinuationFilled ] = useState( false ); - const [ gotoContinuationOnError, setGotoContinuationOnError ] = - useState( false ); - - const [ paypalScriptLoaded, setPaypalScriptLoaded ] = useState( false ); - - if ( ! paypalScriptLoaded ) { - if ( ! paypalScriptPromise ) { - // for editor, since canMakePayment was not called - paypalScriptPromise = loadPayPalScript( - namespace, - config.scriptData - ); - } - paypalScriptPromise.then( () => setPaypalScriptLoaded( true ) ); - } - - const methodId = fundingSource - ? `${ config.id }-${ fundingSource }` - : config.id; - - /** - * The block cart displays express checkout buttons. Those buttons are handled by the - * PAYPAL_GATEWAY_ID method on the server ("PayPal Smart Buttons"). - * - * A possible bug in WooCommerce does not use the correct payment method ID for the express - * payment buttons inside the cart, but sends the ID of the _first_ active payment method. - * - * This function uses an internal WooCommerce dispatcher method to set the correct method ID. - */ - const enforcePaymentMethodForCart = () => { - // Do nothing, unless we're handling block cart express payment buttons. - if ( 'cart-block' !== config.scriptData.context ) { - return; - } - - // Set the active payment method to PAYPAL_GATEWAY_ID. - wp.data - .dispatch( 'wc/store/payment' ) - .__internalSetActivePaymentMethod( PAYPAL_GATEWAY_ID, {} ); - }; - - useEffect( () => { - // fill the form if in continuation (for product or mini-cart buttons) - if ( continuationFilled || ! config.scriptData.continuation?.order ) { - return; - } - - try { - const paypalAddresses = paypalOrderToWcAddresses( - config.scriptData.continuation.order - ); - const wcAddresses = wp.data - .select( 'wc/store/cart' ) - .getCustomerData(); - const addresses = mergeWcAddress( wcAddresses, paypalAddresses ); - - wp.data - .dispatch( 'wc/store/cart' ) - .setBillingAddress( addresses.billingAddress ); - - if ( shippingData.needsShipping ) { - wp.data - .dispatch( 'wc/store/cart' ) - .setShippingAddress( addresses.shippingAddress ); - } - } catch ( err ) { - // sometimes the PayPal address is missing, skip in this case. - console.log( err ); - } - - // this useEffect should run only once, but adding this in case of some kind of full re-rendering - setContinuationFilled( true ); - }, [ shippingData, continuationFilled ] ); - - const createOrder = async ( data, actions ) => { - try { - const requestBody = { - nonce: config.scriptData.ajax.create_order.nonce, - bn_code: '', - context: config.scriptData.context, - payment_method: 'ppcp-gateway', - funding_source: window.ppcpFundingSource ?? 'paypal', - createaccount: false, - ...( data?.paymentSource && { - payment_source: data.paymentSource, - } ), - }; - - const res = await fetch( - config.scriptData.ajax.create_order.endpoint, - { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( requestBody ), - } - ); - - const json = await res.json(); - - if ( ! json.success ) { - if ( json.data?.details?.length > 0 ) { - throw new Error( - json.data.details - .map( ( d ) => `${ d.issue } ${ d.description }` ) - .join( '
' ) - ); - } else if ( json.data?.message ) { - throw new Error( json.data.message ); - } - - throw new Error( config.scriptData.labels.error.generic ); - } - return json.data.id; - } catch ( err ) { - console.error( err ); - - onError( err.message ); - - onClose(); - - throw err; - } - }; - - const createSubscription = async ( data, actions ) => { - let planId = config.scriptData.subscription_plan_id; - if ( - config.scriptData - .variable_paypal_subscription_variation_from_cart !== '' - ) { - planId = - config.scriptData - .variable_paypal_subscription_variation_from_cart; - } - - return actions.subscription.create( { - plan_id: planId, - } ); - }; - - const handleApproveSubscription = async ( data, actions ) => { - try { - const subscription = await actions.subscription.get(); - - if ( subscription ) { - const addresses = - paypalSubscriptionToWcAddresses( subscription ); - - const promises = [ - // save address on server - wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - billing_address: addresses.billingAddress, - shipping_address: addresses.shippingAddress, - } ), - ]; - if ( shouldHandleShippingInPayPal() ) { - // set address in UI - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setBillingAddress( addresses.billingAddress ) - ); - if ( shippingData.needsShipping ) { - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setShippingAddress( addresses.shippingAddress ) - ); - } - } - await Promise.all( promises ); - } - - setPaypalOrder( subscription ); - - const res = await fetch( - config.scriptData.ajax.approve_subscription.endpoint, - { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.scriptData.ajax.approve_subscription - .nonce, - order_id: data.orderID, - subscription_id: data.subscriptionID, - } ), - } - ); - - const json = await res.json(); - - if ( ! json.success ) { - if ( - typeof actions !== 'undefined' && - typeof actions.restart !== 'undefined' - ) { - return actions.restart(); - } - if ( json.data?.message ) { - throw new Error( json.data.message ); - } - - throw new Error( config.scriptData.labels.error.generic ); - } - - if ( ! shouldskipFinalConfirmation() ) { - location.href = getCheckoutRedirectUrl(); - } else { - setGotoContinuationOnError( true ); - enforcePaymentMethodForCart(); - onSubmit(); - } - } catch ( err ) { - console.error( err ); - - onError( err.message ); - - onClose(); - - throw err; - } - }; - - const getCheckoutRedirectUrl = () => { - const checkoutUrl = new URL( config.scriptData.redirect ); - // sometimes some browsers may load some kind of cached version of the page, - // so adding a parameter to avoid that - checkoutUrl.searchParams.append( - 'ppcp-continuation-redirect', - new Date().getTime().toString() - ); - return checkoutUrl.toString(); - }; - - const handleApprove = async ( data, actions ) => { - try { - const order = await actions.order.get(); - - if ( order ) { - const addresses = paypalOrderToWcAddresses( order ); - - const promises = [ - // save address on server - wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - billing_address: addresses.billingAddress, - shipping_address: addresses.shippingAddress, - } ), - ]; - if ( shouldHandleShippingInPayPal() ) { - // set address in UI - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setBillingAddress( addresses.billingAddress ) - ); - if ( shippingData.needsShipping ) { - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setShippingAddress( addresses.shippingAddress ) - ); - } - } - await Promise.all( promises ); - } - - setPaypalOrder( order ); - - const res = await fetch( - config.scriptData.ajax.approve_order.endpoint, - { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.scriptData.ajax.approve_order.nonce, - order_id: data.orderID, - funding_source: window.ppcpFundingSource ?? 'paypal', - } ), - } - ); - - const json = await res.json(); - - if ( ! json.success ) { - if ( - typeof actions !== 'undefined' && - typeof actions.restart !== 'undefined' - ) { - return actions.restart(); - } - if ( json.data?.message ) { - throw new Error( json.data.message ); - } - - throw new Error( config.scriptData.labels.error.generic ); - } - - if ( ! shouldskipFinalConfirmation() ) { - location.href = getCheckoutRedirectUrl(); - } else { - setGotoContinuationOnError( true ); - enforcePaymentMethodForCart(); - onSubmit(); - } - } catch ( err ) { - console.error( err ); - - onError( err.message ); - - onClose(); - - throw err; - } - }; - - useEffect( () => { - const unsubscribe = onCheckoutValidation( () => { - if ( config.scriptData.continuation ) { - return true; - } - if ( - gotoContinuationOnError && - wp.data.select( 'wc/store/validation' ).hasValidationErrors() - ) { - location.href = getCheckoutRedirectUrl(); - return { type: responseTypes.ERROR }; - } - - return true; - } ); - return unsubscribe; - }, [ onCheckoutValidation, gotoContinuationOnError ] ); - - const handleClick = ( data, actions ) => { - if ( isEditing ) { - return actions.reject(); - } - - window.ppcpFundingSource = data.fundingSource; - - onClick(); - }; - - const shouldHandleShippingInPayPal = () => { - return shouldskipFinalConfirmation() && config.needShipping; - }; - - const shouldskipFinalConfirmation = () => { - if ( config.finalReviewEnabled ) { - return false; - } - - return ( - window.ppcpFundingSource !== 'venmo' || - ! config.scriptData.vaultingEnabled - ); - }; - - let handleShippingOptionsChange = null; - let handleShippingAddressChange = null; - let handleSubscriptionShippingOptionsChange = null; - let handleSubscriptionShippingAddressChange = null; - - if ( shippingData.needsShipping && shouldHandleShippingInPayPal() ) { - handleShippingOptionsChange = async ( data, actions ) => { - try { - const shippingOptionId = data.selectedShippingOption?.id; - if ( shippingOptionId ) { - await wp.data - .dispatch( 'wc/store/cart' ) - .selectShippingRate( shippingOptionId ); - await shippingData.setSelectedRates( shippingOptionId ); - } - - const res = await fetch( config.ajax.update_shipping.endpoint, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.ajax.update_shipping.nonce, - order_id: data.orderID, - } ), - } ); - - const json = await res.json(); - - if ( ! json.success ) { - throw new Error( json.data.message ); - } - } catch ( e ) { - console.error( e ); - - actions.reject(); - } - }; - - handleShippingAddressChange = async ( data, actions ) => { - try { - const address = paypalAddressToWc( - convertKeysToSnakeCase( data.shippingAddress ) - ); - - await wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - shipping_address: address, - } ); - - await shippingData.setShippingAddress( address ); - - const res = await fetch( config.ajax.update_shipping.endpoint, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.ajax.update_shipping.nonce, - order_id: data.orderID, - } ), - } ); - - const json = await res.json(); - - if ( ! json.success ) { - throw new Error( json.data.message ); - } - } catch ( e ) { - console.error( e ); - - actions.reject(); - } - }; - - handleSubscriptionShippingOptionsChange = async ( data, actions ) => { - try { - const shippingOptionId = data.selectedShippingOption?.id; - if ( shippingOptionId ) { - await wp.data - .dispatch( 'wc/store/cart' ) - .selectShippingRate( shippingOptionId ); - await shippingData.setSelectedRates( shippingOptionId ); - } - } catch ( e ) { - console.error( e ); - - actions.reject(); - } - }; - - handleSubscriptionShippingAddressChange = async ( data, actions ) => { - try { - const address = paypalAddressToWc( - convertKeysToSnakeCase( data.shippingAddress ) - ); - - await wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - shipping_address: address, - } ); - - await shippingData.setShippingAddress( address ); - - const res = await fetch( config.ajax.update_shipping.endpoint, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.ajax.update_shipping.nonce, - order_id: data.orderID, - } ), - } ); - - const json = await res.json(); - - if ( ! json.success ) { - throw new Error( json.data.message ); - } - } catch ( e ) { - console.error( e ); - - actions.reject(); - } - }; - } - - useEffect( () => { - if ( activePaymentMethod !== methodId ) { - return; - } - - const unsubscribeProcessing = onPaymentSetup( () => { - if ( config.scriptData.continuation ) { - return { - type: responseTypes.SUCCESS, - meta: { - paymentMethodData: { - paypal_order_id: - config.scriptData.continuation.order_id, - funding_source: - window.ppcpFundingSource ?? 'paypal', - }, - }, - }; - } - - const addresses = paypalOrderToWcAddresses( paypalOrder ); - - return { - type: responseTypes.SUCCESS, - meta: { - paymentMethodData: { - paypal_order_id: paypalOrder.id, - funding_source: window.ppcpFundingSource ?? 'paypal', - }, - ...addresses, - }, - }; - } ); - return () => { - unsubscribeProcessing(); - }; - }, [ onPaymentSetup, paypalOrder, activePaymentMethod ] ); - - useEffect( () => { - if ( activePaymentMethod !== methodId ) { - return; - } - const unsubscribe = onCheckoutFail( ( { processingResponse } ) => { - console.error( processingResponse ); - if ( onClose ) { - onClose(); - } - if ( config.scriptData.continuation ) { - return true; - } - if ( shouldskipFinalConfirmation() ) { - location.href = getCheckoutRedirectUrl(); - } - return true; - } ); - return unsubscribe; - }, [ onCheckoutFail, onClose, activePaymentMethod ] ); - - if ( config.scriptData.continuation ) { - return ( -
- ); - } - - if ( ! registeredContext ) { - buttonModuleWatcher.registerContextBootstrap( - config.scriptData.context, - { - createOrder: () => { - return createOrder(); - }, - onApprove: ( data, actions ) => { - return handleApprove( data, actions ); - }, - } - ); - registeredContext = true; - } - - const style = normalizeStyleForFundingSource( - config.scriptData.button.style, - fundingSource - ); - - if ( typeof buttonAttributes !== 'undefined' ) { - style.height = buttonAttributes?.height - ? Number( buttonAttributes.height ) - : style.height; - style.borderRadius = buttonAttributes?.borderRadius - ? Number( buttonAttributes.borderRadius ) - : style.borderRadius; - } - - if ( ! paypalScriptLoaded ) { - return null; - } - - const PayPalButton = ppcpBlocksPaypalExpressButtons.Buttons.driver( - 'react', - { React, ReactDOM } - ); - - const getOnShippingOptionsChange = ( fundingSource ) => { - if ( fundingSource === 'venmo' ) { - return null; - } - - return ( data, actions ) => { - shouldHandleShippingInPayPal() - ? handleShippingOptionsChange( data, actions ) - : null; - }; - }; - - const getOnShippingAddressChange = ( fundingSource ) => { - if ( fundingSource === 'venmo' ) { - return null; - } - - return ( data, actions ) => { - const shippingAddressChange = shouldHandleShippingInPayPal() - ? handleShippingAddressChange( data, actions ) - : null; - - return shippingAddressChange; - }; - }; - - if ( isPayPalSubscription( config.scriptData ) ) { - return ( - - ); - } - - return ( - - ); -}; - -const BlockEditorPayPalComponent = ( { fundingSource, buttonAttributes } ) => { - const urlParams = useMemo( - () => ( { - clientId: 'test', - ...config.scriptData.url_params, - dataNamespace: 'ppcp-blocks-editor-paypal-buttons', - components: 'buttons', - } ), - [] - ); - - const style = useMemo( () => { - const configStyle = normalizeStyleForFundingSource( - config.scriptData.button.style, - fundingSource - ); - - if ( buttonAttributes ) { - return { - ...configStyle, - height: buttonAttributes.height - ? Number( buttonAttributes.height ) - : configStyle.height, - borderRadius: buttonAttributes.borderRadius - ? Number( buttonAttributes.borderRadius ) - : configStyle.borderRadius, - }; - } - - return configStyle; - }, [ fundingSource, buttonAttributes ] ); - - return ( - - false } - /> - - ); -}; - const features = [ 'products' ]; -let block_enabled = true; +let blockEnabled = true; if ( cartHasSubscriptionProducts( config.scriptData ) ) { // Don't show buttons on block cart page if using vault v2 and user is not logged in @@ -754,7 +30,17 @@ if ( cartHasSubscriptionProducts( config.scriptData ) ) { ! isPayPalSubscription( config.scriptData ) && // using vaulting ! config.scriptData?.save_payment_methods?.id_token // not vault v3 ) { - block_enabled = false; + blockEnabled = false; + } + + // Don't show buttons on block cart page if user is not logged in and cart contains free trial product + if ( + ! config.scriptData.user.is_logged && + config.scriptData.context === 'cart-block' && + cartHasSubscriptionProducts( config.scriptData ) && + config.scriptData.is_free_trial_cart + ) { + blockEnabled = false; } // Don't render if vaulting disabled and is in vault subscription mode @@ -762,7 +48,7 @@ if ( cartHasSubscriptionProducts( config.scriptData ) ) { ! isPayPalSubscription( config.scriptData ) && ! config.scriptData.can_save_vault_token ) { - block_enabled = false; + blockEnabled = false; } // Don't render buttons if in subscription mode and product not associated with a PayPal subscription @@ -770,13 +56,21 @@ if ( cartHasSubscriptionProducts( config.scriptData ) ) { isPayPalSubscription( config.scriptData ) && ! config.scriptData.subscription_product_allowed ) { - block_enabled = false; + blockEnabled = false; + } + + // Don't show buttons if cart contains free trial product and the stroe is not eligible for saving payment methods. + if ( + ! config.scriptData.vault_v3_enabled && + config.scriptData.is_free_trial_cart + ) { + blockEnabled = false; } features.push( 'subscriptions' ); } -if ( block_enabled ) { +if ( blockEnabled ) { if ( config.placeOrderEnabled && ! config.scriptData.continuation ) { let descriptionElement = (
{ - const { PaymentMethodIcons } = components; - - return ( - <> - - - - ); - }; - registerPaymentMethod( { name: config.id, label: , @@ -837,8 +116,13 @@ if ( block_enabled ) { registerPaymentMethod( { name: config.id, label:
, - content: , - edit: , + content: , + edit: ( + + ), ariaLabel: config.title, canMakePayment: () => { return true; @@ -848,10 +132,11 @@ if ( block_enabled ) { }, } ); } else if ( config.smartButtonsEnabled ) { - for ( const fundingSource of [ - 'paypal', - ...config.enabledFundingSources, - ] ) { + const fundingSources = config.scriptData.is_free_trial_cart + ? [ 'paypal' ] + : [ 'paypal', ...config.enabledFundingSources ]; + + for ( const fundingSource of fundingSources ) { registerExpressPaymentMethod( { name: `${ config.id }-${ fundingSource }`, title: 'PayPal', @@ -866,12 +151,14 @@ if ( block_enabled ) { ), content: ( ), edit: ( ), diff --git a/modules/ppcp-blocks/resources/js/paypal-config.js b/modules/ppcp-blocks/resources/js/paypal-config.js new file mode 100644 index 000000000..d78ee14db --- /dev/null +++ b/modules/ppcp-blocks/resources/js/paypal-config.js @@ -0,0 +1,316 @@ +import { + paypalOrderToWcAddresses, + paypalSubscriptionToWcAddresses, +} from './Helper/Address'; + +export const createOrder = async ( data, config, onError, onClose ) => { + try { + const requestBody = { + nonce: config.scriptData.ajax.create_order.nonce, + bn_code: '', + context: config.scriptData.context, + payment_method: 'ppcp-gateway', + funding_source: window.ppcpFundingSource ?? 'paypal', + createaccount: false, + ...( data?.paymentSource && { + payment_source: data.paymentSource, + } ), + }; + + const res = await fetch( config.scriptData.ajax.create_order.endpoint, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( requestBody ), + } ); + + const json = await res.json(); + + if ( ! json.success ) { + if ( json.data?.details?.length > 0 ) { + throw new Error( + json.data.details + .map( ( d ) => `${ d.issue } ${ d.description }` ) + .join( '
' ) + ); + } else if ( json.data?.message ) { + throw new Error( json.data.message ); + } + + throw new Error( config.scriptData.labels.error.generic ); + } + + return json.data.id; + } catch ( err ) { + console.error( err ); + + onError( err.message ); + + onClose(); + + throw err; + } +}; + +export const handleApprove = async ( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose +) => { + try { + const order = await actions.order.get(); + + if ( order ) { + const addresses = paypalOrderToWcAddresses( order ); + + const promises = [ + // save address on server + wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { + billing_address: addresses.billingAddress, + shipping_address: addresses.shippingAddress, + } ), + ]; + if ( shouldHandleShippingInPayPal() ) { + // set address in UI + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setBillingAddress( addresses.billingAddress ) + ); + if ( shippingData.needsShipping ) { + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setShippingAddress( addresses.shippingAddress ) + ); + } + } + await Promise.all( promises ); + } + + setPaypalOrder( order ); + + const res = await fetch( + config.scriptData.ajax.approve_order.endpoint, + { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( { + nonce: config.scriptData.ajax.approve_order.nonce, + order_id: data.orderID, + funding_source: window.ppcpFundingSource ?? 'paypal', + } ), + } + ); + + const json = await res.json(); + + if ( ! json.success ) { + if ( + typeof actions !== 'undefined' && + typeof actions.restart !== 'undefined' + ) { + return actions.restart(); + } + if ( json.data?.message ) { + throw new Error( json.data.message ); + } + + throw new Error( config.scriptData.labels.error.generic ); + } + + if ( ! shouldskipFinalConfirmation() ) { + location.href = getCheckoutRedirectUrl(); + } else { + setGotoContinuationOnError( true ); + enforcePaymentMethodForCart(); + onSubmit(); + } + } catch ( err ) { + console.error( err ); + + onError( err.message ); + + onClose(); + + throw err; + } +}; + +export const createSubscription = async ( data, actions, config ) => { + let planId = config.scriptData.subscription_plan_id; + if ( + config.scriptData.variable_paypal_subscription_variation_from_cart !== + '' + ) { + planId = + config.scriptData.variable_paypal_subscription_variation_from_cart; + } + + return actions.subscription.create( { + plan_id: planId, + } ); +}; + +export const handleApproveSubscription = async ( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose +) => { + try { + const subscription = await actions.subscription.get(); + + if ( subscription ) { + const addresses = paypalSubscriptionToWcAddresses( subscription ); + + const promises = [ + // save address on server + wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { + billing_address: addresses.billingAddress, + shipping_address: addresses.shippingAddress, + } ), + ]; + if ( shouldHandleShippingInPayPal() ) { + // set address in UI + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setBillingAddress( addresses.billingAddress ) + ); + if ( shippingData.needsShipping ) { + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setShippingAddress( addresses.shippingAddress ) + ); + } + } + await Promise.all( promises ); + } + + setPaypalOrder( subscription ); + + const res = await fetch( + config.scriptData.ajax.approve_subscription.endpoint, + { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( { + nonce: config.scriptData.ajax.approve_subscription.nonce, + order_id: data.orderID, + subscription_id: data.subscriptionID, + } ), + } + ); + + const json = await res.json(); + + if ( ! json.success ) { + if ( + typeof actions !== 'undefined' && + typeof actions.restart !== 'undefined' + ) { + return actions.restart(); + } + if ( json.data?.message ) { + throw new Error( json.data.message ); + } + + throw new Error( config.scriptData.labels.error.generic ); + } + + if ( ! shouldskipFinalConfirmation() ) { + location.href = getCheckoutRedirectUrl(); + } else { + setGotoContinuationOnError( true ); + enforcePaymentMethodForCart(); + onSubmit(); + } + } catch ( err ) { + console.error( err ); + + onError( err.message ); + + onClose(); + + throw err; + } +}; + +export const createVaultSetupToken = async ( config ) => { + return fetch( config.scriptData.ajax.create_setup_token.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { + nonce: config.scriptData.ajax.create_setup_token.nonce, + payment_method: 'ppcp-gateway', + } ), + } ) + .then( ( response ) => response.json() ) + .then( ( result ) => { + return result.data.id; + } ) + .catch( ( err ) => { + console.error( err ); + } ); +}; + +export const onApproveSavePayment = async ( + vaultSetupToken, + config, + onSubmit +) => { + let endpoint = + config.scriptData.ajax.create_payment_token_for_guest.endpoint; + let bodyContent = { + nonce: config.scriptData.ajax.create_payment_token_for_guest.nonce, + vault_setup_token: vaultSetupToken, + }; + + if ( config.scriptData.user.is_logged_in ) { + endpoint = config.scriptData.ajax.create_payment_token.endpoint; + + bodyContent = { + nonce: config.scriptData.ajax.create_payment_token.nonce, + vault_setup_token: vaultSetupToken, + is_free_trial_cart: config.scriptData.is_free_trial_cart, + }; + } + + const response = await fetch( endpoint, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( bodyContent ), + } ); + + const result = await response.json(); + if ( result.success === true ) { + onSubmit(); + } + + console.error( result ); +}; diff --git a/modules/ppcp-blocks/src/AdvancedCardPaymentMethod.php b/modules/ppcp-blocks/src/AdvancedCardPaymentMethod.php index c52d7e601..222d53e22 100644 --- a/modules/ppcp-blocks/src/AdvancedCardPaymentMethod.php +++ b/modules/ppcp-blocks/src/AdvancedCardPaymentMethod.php @@ -97,11 +97,16 @@ class AdvancedCardPaymentMethod extends AbstractPaymentMethodType { wp_register_script( 'ppcp-advanced-card-checkout-block', trailingslashit( $this->module_url ) . 'assets/js/advanced-card-checkout-block.js', - array(), + array( 'wp-i18n' ), $this->version, true ); + wp_set_script_translations( + 'ppcp-advanced-card-checkout-block', + 'woocommerce-paypal-payments' + ); + return array( 'ppcp-advanced-card-checkout-block' ); } diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index a08c20b27..fd1b93404 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -1910,7 +1910,7 @@ document.querySelector("#payment").before(document.querySelector(".ppcp-messages $in_stock = $product->is_in_stock(); - if ( $product->is_type( 'variable' ) ) { + if ( ! $in_stock && $product->is_type( 'variable' ) ) { /** * The method is defined in WC_Product_Variable class. * diff --git a/modules/ppcp-compat/services.php b/modules/ppcp-compat/services.php index 07a6da27f..7982366e0 100644 --- a/modules/ppcp-compat/services.php +++ b/modules/ppcp-compat/services.php @@ -121,7 +121,7 @@ return array( * * @returns SettingsMap[] */ - 'compat.setting.new-to-old-map' => function( ContainerInterface $container ) : array { + 'compat.setting.new-to-old-map' => static function( ContainerInterface $container ) : array { $are_new_settings_enabled = $container->get( 'wcgateway.settings.admin-settings-enabled' ); if ( ! $are_new_settings_enabled ) { return array(); @@ -129,7 +129,7 @@ return array( return array( new SettingsMap( - $container->get( 'settings.data.common' ), + $container->get( 'settings.data.general' ), array( 'client_id' => 'client_id', 'client_secret' => 'client_secret', @@ -137,16 +137,23 @@ return array( ), new SettingsMap( $container->get( 'settings.data.general' ), + /** + * The new GeneralSettings class stores the current connection + * details, without adding an environment-suffix (no `_sandbox` + * or `_production` in the field name) + * Only the `sandbox_merchant` flag indicates, which environment + * the credentials are used for. + */ array( - 'is_sandbox' => 'sandbox_on', - 'live_client_id' => 'client_id_production', - 'live_client_secret' => 'client_secret_production', - 'live_merchant_id' => 'merchant_id_production', - 'live_merchant_email' => 'merchant_email_production', - 'sandbox_client_id' => 'client_id_sandbox', - 'sandbox_client_secret' => 'client_secret_sandbox', - 'sandbox_merchant_id' => 'merchant_id_sandbox', - 'sandbox_merchant_email' => 'merchant_email_sandbox', + 'is_sandbox' => 'sandbox_merchant', + 'live_client_id' => 'client_id', + 'live_client_secret' => 'client_secret', + 'live_merchant_id' => 'merchant_id', + 'live_merchant_email' => 'merchant_email', + 'sandbox_client_id' => 'client_id', + 'sandbox_client_secret' => 'client_secret', + 'sandbox_merchant_id' => 'merchant_id', + 'sandbox_merchant_email' => 'merchant_email', ) ), ); diff --git a/modules/ppcp-compat/src/CompatModule.php b/modules/ppcp-compat/src/CompatModule.php index d52e13454..c8fdc9d8e 100644 --- a/modules/ppcp-compat/src/CompatModule.php +++ b/modules/ppcp-compat/src/CompatModule.php @@ -87,6 +87,8 @@ class CompatModule implements ServiceModule, ExtendingModule, ExecutableModule { $this->initialize_wc_bookings_compat_layer( $c ); } + add_action( 'woocommerce_paypal_payments_gateway_migrate', static fn() => delete_transient( 'ppcp_has_ppec_subscriptions' ) ); + return true; } diff --git a/modules/ppcp-compat/src/PPEC/PPECHelper.php b/modules/ppcp-compat/src/PPEC/PPECHelper.php index a01c5eea7..c74aac61d 100644 --- a/modules/ppcp-compat/src/PPEC/PPECHelper.php +++ b/modules/ppcp-compat/src/PPEC/PPECHelper.php @@ -75,10 +75,10 @@ class PPECHelper { } global $wpdb; - if ( class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() && isset( $wpdb->wc_orders ) ) { + if ( class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() ) { $result = $wpdb->get_var( $wpdb->prepare( - "SELECT 1 FROM {$wpdb->wc_orders} WHERE payment_method = %s", + "SELECT 1 FROM {$wpdb->prefix}wc_orders WHERE payment_method = %s", self::PPEC_GATEWAY_ID ) ); diff --git a/modules/ppcp-googlepay/src/GooglepayModule.php b/modules/ppcp-googlepay/src/GooglepayModule.php index 01d5f8fae..dd7320011 100644 --- a/modules/ppcp-googlepay/src/GooglepayModule.php +++ b/modules/ppcp-googlepay/src/GooglepayModule.php @@ -234,21 +234,17 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul add_filter( 'woocommerce_paypal_payments_rest_common_merchant_data', - function ( array $merchant_data ) use ( $c ): array { - if ( ! isset( $merchant_data['features'] ) ) { - $merchant_data['features'] = array(); - } - + function ( array $features ) use ( $c ): array { $product_status = $c->get( 'googlepay.helpers.apm-product-status' ); assert( $product_status instanceof ApmProductStatus ); $google_pay_enabled = $product_status->is_active(); - $merchant_data['features']['google_pay'] = array( + $features['google_pay'] = array( 'enabled' => $google_pay_enabled, ); - return $merchant_data; + return $features; } ); diff --git a/modules/ppcp-onboarding/resources/js/onboarding.js b/modules/ppcp-onboarding/resources/js/onboarding.js index 5a6ab333a..06197afd6 100644 --- a/modules/ppcp-onboarding/resources/js/onboarding.js +++ b/modules/ppcp-onboarding/resources/js/onboarding.js @@ -345,6 +345,10 @@ window.ppcp_onboarding_productionCallback = function ( ...args ) { const sandboxSwitchElement = document.querySelector( '#ppcp-sandbox_on' ); + sandboxSwitchElement?.addEventListener( 'click', () => { + document.querySelector( '.woocommerce-save-button' )?.removeAttribute( 'disabled' ); + }); + const validate = () => { const selectors = sandboxSwitchElement.checked ? sandboxCredentialElementsSelectors diff --git a/modules/ppcp-onboarding/services.php b/modules/ppcp-onboarding/services.php index 56a49be8e..2a6de82d8 100644 --- a/modules/ppcp-onboarding/services.php +++ b/modules/ppcp-onboarding/services.php @@ -14,17 +14,15 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; 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\Helper\Cache; use WooCommerce\PayPalCommerce\Onboarding\Assets\OnboardingAssets; use WooCommerce\PayPalCommerce\Onboarding\Endpoint\LoginSellerEndpoint; use WooCommerce\PayPalCommerce\Onboarding\Endpoint\UpdateSignupLinksEndpoint; use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingSendOnlyNoticeRenderer; 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' ); @@ -38,7 +36,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' ); @@ -53,7 +51,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' ); /** @@ -65,7 +63,7 @@ return array( ? (string) $container->get( 'api.sandbox-host' ) : (string) $container->get( 'api.production-host' ); }, - 'api.paypal-host' => function( ContainerInterface $container ) : string { + 'api.paypal-host' => function( ContainerInterface $container ) : string { $environment = $container->get( 'onboarding.environment' ); /** * The current environment. @@ -78,7 +76,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 ) ) { @@ -88,7 +86,7 @@ return array( }, - 'api.bearer' => static function ( ContainerInterface $container ): Bearer { + 'api.bearer' => static function ( ContainerInterface $container ): Bearer { $state = $container->get( 'onboarding.state' ); @@ -115,16 +113,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( @@ -137,34 +135,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 { - - $logger = $container->get( 'woocommerce.logger.woocommerce' ); - return new LoginSeller( - $container->get( 'api.paypal-host-production' ), - $container->get( 'api.partner_merchant_id-production' ), - $logger - ); - }, - - 'api.endpoint.login-seller-sandbox' => static function ( ContainerInterface $container ) : LoginSeller { - - $logger = $container->get( 'woocommerce.logger.woocommerce' ); - return new LoginSeller( - $container->get( 'api.paypal-host-sandbox' ), - $container->get( 'api.partner_merchant_id-sandbox' ), - $logger - ); - }, - - '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' ); @@ -184,7 +162,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' ), @@ -194,10 +172,10 @@ return array( $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', @@ -205,12 +183,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' ); @@ -226,14 +204,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-paylater-configurator/src/PayLaterConfiguratorModule.php b/modules/ppcp-paylater-configurator/src/PayLaterConfiguratorModule.php index 2c13bed71..71f33e1da 100644 --- a/modules/ppcp-paylater-configurator/src/PayLaterConfiguratorModule.php +++ b/modules/ppcp-paylater-configurator/src/PayLaterConfiguratorModule.php @@ -69,7 +69,11 @@ class PayLaterConfiguratorModule implements ServiceModule, ExtendingModule, Exec $is_wc_settings_page = $c->get( 'wcgateway.is-wc-settings-page' ); $messaging_locations = $c->get( 'paylater-configurator.messaging-locations' ); - self::add_paylater_update_notice( $messaging_locations, $is_wc_settings_page, $current_page_id ); + self::add_paylater_update_notice( + $messaging_locations, + $is_wc_settings_page, + $current_page_id + ); $settings = $c->get( 'wcgateway.settings' ); assert( $settings instanceof Settings ); @@ -159,9 +163,9 @@ class PayLaterConfiguratorModule implements ServiceModule, ExtendingModule, Exec * The notice appears on any PayPal-Settings page, except for the Pay-Later settings page, * when no Pay-Later messaging is used yet. * - * @param array $message_locations PayLater messaging locations. - * @param bool $is_settings_page Whether the current page is a WC settings page. - * @param string $current_page_id ID of current settings page tab. + * @param array $message_locations PayLater messaging locations. + * @param bool $is_settings_page Whether the current page is a WC settings page. + * @param string $current_page_id ID of current settings page tab. * * @return void */ diff --git a/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php index f88fea7d8..236538b9f 100644 --- a/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php +++ b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php @@ -49,6 +49,8 @@ class RenewalHandler { public function process( array $subscriptions, string $transaction_id ): void { foreach ( $subscriptions as $subscription ) { if ( $this->is_for_renewal_order( $subscription ) ) { + $subscription->update_status( 'on-hold' ); + $renewal_order = wcs_create_renewal_order( $subscription ); if ( is_a( $renewal_order, WC_Order::class ) ) { $this->logger->info( diff --git a/modules/ppcp-save-payment-methods/services.php b/modules/ppcp-save-payment-methods/services.php index f0c5802ba..370347e86 100644 --- a/modules/ppcp-save-payment-methods/services.php +++ b/modules/ppcp-save-payment-methods/services.php @@ -20,75 +20,56 @@ return array( $save_payment_methods_applies = $container->get( 'save-payment-methods.helpers.save-payment-methods-applies' ); assert( $save_payment_methods_applies instanceof SavePaymentMethodsApplies ); - return $save_payment_methods_applies->for_country_currency(); + return $save_payment_methods_applies->for_country(); }, 'save-payment-methods.helpers.save-payment-methods-applies' => static function ( ContainerInterface $container ) : SavePaymentMethodsApplies { return new SavePaymentMethodsApplies( - $container->get( 'save-payment-methods.supported-country-currency-matrix' ), - $container->get( 'api.shop.currency.getter' ), + $container->get( 'save-payment-methods.supported-countries' ), $container->get( 'api.shop.country' ) ); }, - 'save-payment-methods.supported-country-currency-matrix' => static function ( ContainerInterface $container ) : array { - $default_currencies = array( - 'AUD', - 'BRL', - 'CAD', - 'CHF', - 'CZK', - 'DKK', - 'EUR', - 'GBP', - 'HUF', - 'ILS', - 'JPY', - 'MXN', - 'NOK', - 'NZD', - 'PHP', - 'PLN', - 'SEK', - 'THB', - 'TWD', - 'USD', - ); + 'save-payment-methods.supported-countries' => static function ( ContainerInterface $container ) : array { + if ( has_filter( 'woocommerce_paypal_payments_save_payment_methods_supported_country_currency_matrix' ) ) { + _deprecated_hook( 'woocommerce_paypal_payments_save_payment_methods_supported_country_currency_matrix', '3.0.0', 'woocommerce_paypal_payments_save_payment_methods_supported_countries', esc_attr__( 'Please use the new Hook to filter countries for saved payments in PayPal Payments.', 'woocommerce-paypal-payments' ) ); + } return apply_filters( - 'woocommerce_paypal_payments_save_payment_methods_supported_country_currency_matrix', + 'woocommerce_paypal_payments_save_payment_methods_supported_countries', array( - 'AU' => $default_currencies, - 'AT' => $default_currencies, - 'BE' => $default_currencies, - 'BG' => $default_currencies, - 'CA' => $default_currencies, - 'CN' => $default_currencies, - 'CY' => $default_currencies, - 'CZ' => $default_currencies, - 'DK' => $default_currencies, - 'EE' => $default_currencies, - 'FI' => $default_currencies, - 'FR' => $default_currencies, - 'DE' => $default_currencies, - 'GR' => $default_currencies, - 'HU' => $default_currencies, - 'IE' => $default_currencies, - 'IT' => $default_currencies, - 'LV' => $default_currencies, - 'LI' => $default_currencies, - 'LT' => $default_currencies, - 'LU' => $default_currencies, - 'MT' => $default_currencies, - 'NO' => $default_currencies, - 'NL' => $default_currencies, - 'PL' => $default_currencies, - 'PT' => $default_currencies, - 'RO' => $default_currencies, - 'SK' => $default_currencies, - 'SI' => $default_currencies, - 'ES' => $default_currencies, - 'SE' => $default_currencies, - 'GB' => $default_currencies, - 'US' => $default_currencies, + 'AU', + 'AT', + 'BE', + 'BG', + 'CA', + 'CN', + 'CY', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'HK', + 'HU', + 'IE', + 'IT', + 'LV', + 'LI', + 'LT', + 'LU', + 'MT', + 'NO', + 'NL', + 'PL', + 'PT', + 'RO', + 'SG', + 'SK', + 'SI', + 'ES', + 'SE', + 'GB', + 'US', ) ); }, diff --git a/modules/ppcp-save-payment-methods/src/Endpoint/CreatePaymentToken.php b/modules/ppcp-save-payment-methods/src/Endpoint/CreatePaymentToken.php index 434a08925..b89d17e53 100644 --- a/modules/ppcp-save-payment-methods/src/Endpoint/CreatePaymentToken.php +++ b/modules/ppcp-save-payment-methods/src/Endpoint/CreatePaymentToken.php @@ -96,7 +96,7 @@ class CreatePaymentToken implements EndpointInterface { $customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - $result = $this->payment_method_tokens_endpoint->create_payment_token( $payment_source, $customer_id ); + $result = $this->payment_method_tokens_endpoint->create_payment_token( $payment_source, (string) $customer_id ); if ( is_user_logged_in() && isset( $result->customer->id ) ) { $current_user_id = get_current_user_id(); diff --git a/modules/ppcp-save-payment-methods/src/Endpoint/CreateSetupToken.php b/modules/ppcp-save-payment-methods/src/Endpoint/CreateSetupToken.php index 6952feb43..0b53f0828 100644 --- a/modules/ppcp-save-payment-methods/src/Endpoint/CreateSetupToken.php +++ b/modules/ppcp-save-payment-methods/src/Endpoint/CreateSetupToken.php @@ -105,7 +105,7 @@ class CreateSetupToken implements EndpointInterface { $customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - $result = $this->payment_method_tokens_endpoint->setup_tokens( $payment_source, $customer_id ); + $result = $this->payment_method_tokens_endpoint->setup_tokens( $payment_source, (string) $customer_id ); wp_send_json_success( $result ); return true; diff --git a/modules/ppcp-save-payment-methods/src/Helper/SavePaymentMethodsApplies.php b/modules/ppcp-save-payment-methods/src/Helper/SavePaymentMethodsApplies.php index c7f4d32d7..f920b7904 100644 --- a/modules/ppcp-save-payment-methods/src/Helper/SavePaymentMethodsApplies.php +++ b/modules/ppcp-save-payment-methods/src/Helper/SavePaymentMethodsApplies.php @@ -9,49 +9,37 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\SavePaymentMethods\Helper; -use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter; - /** * Class SavePaymentMethodsApplies */ class SavePaymentMethodsApplies { /** - * The matrix which countries and currency combinations can be used for Save Payment Methods. + * The countries can be used for Save Payment Methods. * * @var array */ - private $allowed_country_currency_matrix; - - /** - * The getter of the 3-letter currency code of the shop. - * - * @var CurrencyGetter - */ - private CurrencyGetter $currency; + private array $allowed_countries; /** * 2-letter country code of the shop. * * @var string */ - private $country; + private string $country; /** * SavePaymentMethodsApplies constructor. * - * @param array $allowed_country_currency_matrix The matrix which countries and currency combinations can be used for Save Payment Methods. - * @param CurrencyGetter $currency The getter of the 3-letter currency code of the shop. - * @param string $country 2-letter country code of the shop. + * @param array $allowed_countries The matrix which countries and currency combinations can be used for Save Payment Methods. + * @param string $country 2-letter country code of the shop. */ public function __construct( - array $allowed_country_currency_matrix, - CurrencyGetter $currency, + array $allowed_countries, string $country ) { - $this->allowed_country_currency_matrix = $allowed_country_currency_matrix; - $this->currency = $currency; - $this->country = $country; + $this->allowed_countries = $allowed_countries; + $this->country = $country; } /** @@ -59,10 +47,8 @@ class SavePaymentMethodsApplies { * * @return bool */ - public function for_country_currency(): bool { - if ( ! in_array( $this->country, array_keys( $this->allowed_country_currency_matrix ), true ) ) { - return false; - } - return in_array( $this->currency->get(), $this->allowed_country_currency_matrix[ $this->country ], true ); + public function for_country(): bool { + + return in_array( $this->country, $this->allowed_countries, true ); } } diff --git a/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php b/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php index c76162193..c5a8776d0 100644 --- a/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php +++ b/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php @@ -66,396 +66,382 @@ class SavePaymentMethodsModule implements ServiceModule, ExtendingModule, Execut add_action( 'woocommerce_paypal_payments_gateway_migrate_on_update', function() use ( $c ) { + $settings = $c->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + $billing_agreements_endpoint = $c->get( 'api.endpoint.billing-agreements' ); assert( $billing_agreements_endpoint instanceof BillingAgreementsEndpoint ); - $reference_transaction_enabled = $billing_agreements_endpoint->reference_transaction_enabled(); if ( $reference_transaction_enabled !== true ) { - $c->get( 'wcgateway.settings' )->set( 'vault_enabled', false ); - $c->get( 'wcgateway.settings' )->persist(); + $settings->set( 'vault_enabled', false ); + $settings->persist(); } } ); - add_filter( - 'woocommerce_paypal_payments_localized_script_data', - function( array $localized_script_data ) use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return $localized_script_data; - } - $subscriptions_helper = $c->get( 'wc-subscriptions.helper' ); - assert( $subscriptions_helper instanceof SubscriptionHelper ); - if ( ! is_user_logged_in() && ! $subscriptions_helper->cart_contains_subscription() ) { - return $localized_script_data; - } - - $api = $c->get( 'api.user-id-token' ); - assert( $api instanceof UserIdToken ); - - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - assert( $logger instanceof LoggerInterface ); - - return $this->add_id_token_to_script_data( $api, $logger, $localized_script_data ); - } - ); - - // Adds attributes needed to save payment method. - add_filter( - 'ppcp_create_order_request_body_data', - function( array $data, string $payment_method, array $request_data ) use ( $c ): array { - if ( ! self::vault_enabled( $c ) ) { - return $data; - } - if ( $payment_method === CreditCardGateway::ID ) { - $save_payment_method = $request_data['save_payment_method'] ?? false; - if ( $save_payment_method ) { - $data['payment_source'] = array( - 'card' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - ), - ), - ), - ); - - $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - if ( ! $target_customer_id ) { - $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); - } - - if ( $target_customer_id ) { - $data['payment_source']['card']['attributes']['customer'] = array( - 'id' => $target_customer_id, - ); - } - } - } - - if ( $payment_method === PayPalGateway::ID ) { - $funding_source = $request_data['funding_source'] ?? null; - - if ( $funding_source && $funding_source === 'venmo' ) { - $data['payment_source'] = array( - 'venmo' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - 'usage_type' => 'MERCHANT', - 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), - ), - ), - ), - ); - } elseif ( $funding_source && $funding_source === 'apple_pay' ) { - $data['payment_source'] = array( - 'apple_pay' => array( - 'stored_credential' => array( - 'payment_initiator' => 'CUSTOMER', - 'payment_type' => 'RECURRING', - ), - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - ), - ), - ), - ); - } else { - $data['payment_source'] = array( - 'paypal' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - 'usage_type' => 'MERCHANT', - 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), - ), - ), - ), - ); - } - } - - return $data; - }, - 10, - 3 - ); - - add_action( - 'woocommerce_paypal_payments_after_order_processor', - function( WC_Order $wc_order, Order $order ) use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - $payment_source = $order->payment_source(); - assert( $payment_source instanceof PaymentSource ); - - $payment_vault_attributes = $payment_source->properties()->attributes->vault ?? null; - if ( $payment_vault_attributes ) { - $customer_id = $payment_vault_attributes->customer->id ?? ''; - $token_id = $payment_vault_attributes->id ?? ''; - if ( ! $customer_id || ! $token_id ) { - return; - } - - update_user_meta( $wc_order->get_customer_id(), '_ppcp_target_customer_id', $customer_id ); - - $wc_payment_tokens = $c->get( 'vaulting.wc-payment-tokens' ); - assert( $wc_payment_tokens instanceof WooCommercePaymentTokens ); - - if ( $wc_order->get_payment_method() === CreditCardGateway::ID ) { - $token = new \WC_Payment_Token_CC(); - $token->set_token( $token_id ); - $token->set_user_id( $wc_order->get_customer_id() ); - $token->set_gateway_id( CreditCardGateway::ID ); - - $token->set_last4( $payment_source->properties()->last_digits ?? '' ); - $expiry = explode( '-', $payment_source->properties()->expiry ?? '' ); - $token->set_expiry_year( $expiry[0] ?? '' ); - $token->set_expiry_month( $expiry[1] ?? '' ); - $token->set_card_type( $payment_source->properties()->brand ?? '' ); - - $token->save(); - } - - if ( $wc_order->get_payment_method() === PayPalGateway::ID ) { - switch ( $payment_source->name() ) { - case 'venmo': - $wc_payment_tokens->create_payment_token_venmo( - $wc_order->get_customer_id(), - $token_id, - $payment_source->properties()->email_address ?? '' - ); - break; - case 'apple_pay': - $wc_payment_tokens->create_payment_token_applepay( - $wc_order->get_customer_id(), - $token_id - ); - break; - case 'paypal': - default: - $wc_payment_tokens->create_payment_token_paypal( - $wc_order->get_customer_id(), - $token_id, - $payment_source->properties()->email_address ?? '' - ); - break; - } - } - } - }, - 10, - 2 - ); - - add_filter( - 'woocommerce_paypal_payments_disable_add_payment_method', - function ( bool $value ) use ( $c ): bool { - if ( ! self::vault_enabled( $c ) ) { - return $value; - } - return false; - } - ); - - add_filter( - 'woocommerce_paypal_payments_should_render_card_custom_fields', - function ( bool $value ) use ( $c ): bool { - if ( ! self::vault_enabled( $c ) ) { - return $value; - } - return false; - } - ); - add_action( - 'wp_enqueue_scripts', - function() use ( $c ) { - if ( ! is_user_logged_in() || ! ( $this->is_add_payment_method_page() || $this->is_subscription_change_payment_method_page() ) || ! self::vault_enabled( $c ) ) { - return; + 'after_setup_theme', + function () use ( $c ) { + $settings = $c->get( 'wcgateway.settings' ); + if ( + ( ! $settings->has( 'vault_enabled' ) || ! $settings->get( 'vault_enabled' ) ) + && ( ! $settings->has( 'vault_enabled_dcc' ) || ! $settings->get( 'vault_enabled_dcc' ) ) + ) { + return true; } - $module_url = $c->get( 'save-payment-methods.module.url' ); - wp_enqueue_script( - 'ppcp-add-payment-method', - untrailingslashit( $module_url ) . '/assets/js/add-payment-method.js', - array( 'jquery' ), - $c->get( 'ppcp.asset-version' ), - true + add_filter( + 'woocommerce_paypal_payments_localized_script_data', + function ( array $localized_script_data ) use ( $c ) { + $subscriptions_helper = $c->get( 'wc-subscriptions.helper' ); + assert( $subscriptions_helper instanceof SubscriptionHelper ); + if ( ! is_user_logged_in() && ! $subscriptions_helper->cart_contains_subscription() ) { + return $localized_script_data; + } + + $api = $c->get( 'api.user-id-token' ); + assert( $api instanceof UserIdToken ); + + $logger = $c->get( 'woocommerce.logger.woocommerce' ); + assert( $logger instanceof LoggerInterface ); + + return $this->add_id_token_to_script_data( $api, $logger, $localized_script_data ); + } ); - $api = $c->get( 'api.user-id-token' ); - assert( $api instanceof UserIdToken ); + // Adds attributes needed to save payment method. + add_filter( + 'ppcp_create_order_request_body_data', + function ( array $data, string $payment_method, array $request_data ) use ( $c ): array { + $settings = $c->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + if ( $payment_method === CreditCardGateway::ID ) { + if ( ! $settings->has( 'vault_enabled_dcc' ) || ! $settings->get( 'vault_enabled_dcc' ) ) { + return $data; + } - try { - $target_customer_id = ''; - if ( is_user_logged_in() ) { - $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - if ( ! $target_customer_id ) { - $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); + $save_payment_method = $request_data['save_payment_method'] ?? false; + if ( $save_payment_method ) { + $data['payment_source'] = array( + 'card' => array( + 'attributes' => array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + ), + ), + ), + ); + + $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); + if ( ! $target_customer_id ) { + $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); + } + + if ( $target_customer_id ) { + $data['payment_source']['card']['attributes']['customer'] = array( + 'id' => $target_customer_id, + ); + } + } + } + + if ( $payment_method === PayPalGateway::ID ) { + if ( ! $settings->has( 'vault_enabled' ) || ! $settings->get( 'vault_enabled' ) ) { + return $data; + } + + $funding_source = $request_data['funding_source'] ?? null; + + if ( $funding_source && $funding_source === 'venmo' ) { + $data['payment_source'] = array( + 'venmo' => array( + 'attributes' => array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + 'usage_type' => 'MERCHANT', + 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), + ), + ), + ), + ); + } elseif ( $funding_source && $funding_source === 'apple_pay' ) { + $data['payment_source'] = array( + 'apple_pay' => array( + 'stored_credential' => array( + 'payment_initiator' => 'CUSTOMER', + 'payment_type' => 'RECURRING', + ), + 'attributes' => array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + ), + ), + ), + ); + } else { + $data['payment_source'] = array( + 'paypal' => array( + 'attributes' => array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + 'usage_type' => 'MERCHANT', + 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), + ), + ), + ), + ); + } + } + + return $data; + }, + 10, + 3 + ); + + add_action( + 'woocommerce_paypal_payments_after_order_processor', + function ( WC_Order $wc_order, Order $order ) use ( $c ) { + $payment_source = $order->payment_source(); + assert( $payment_source instanceof PaymentSource ); + + $payment_vault_attributes = $payment_source->properties()->attributes->vault ?? null; + if ( $payment_vault_attributes ) { + $customer_id = $payment_vault_attributes->customer->id ?? ''; + $token_id = $payment_vault_attributes->id ?? ''; + if ( ! $customer_id || ! $token_id ) { + return; + } + + update_user_meta( $wc_order->get_customer_id(), '_ppcp_target_customer_id', $customer_id ); + + $wc_payment_tokens = $c->get( 'vaulting.wc-payment-tokens' ); + assert( $wc_payment_tokens instanceof WooCommercePaymentTokens ); + + if ( $wc_order->get_payment_method() === CreditCardGateway::ID ) { + $token = new \WC_Payment_Token_CC(); + $token->set_token( $token_id ); + $token->set_user_id( $wc_order->get_customer_id() ); + $token->set_gateway_id( CreditCardGateway::ID ); + + $token->set_last4( $payment_source->properties()->last_digits ?? '' ); + $expiry = explode( '-', $payment_source->properties()->expiry ?? '' ); + $token->set_expiry_year( $expiry[0] ?? '' ); + $token->set_expiry_month( $expiry[1] ?? '' ); + $token->set_card_type( $payment_source->properties()->brand ?? '' ); + + $token->save(); + } + + if ( $wc_order->get_payment_method() === PayPalGateway::ID ) { + switch ( $payment_source->name() ) { + case 'venmo': + $wc_payment_tokens->create_payment_token_venmo( + $wc_order->get_customer_id(), + $token_id, + $payment_source->properties()->email_address ?? '' + ); + break; + case 'apple_pay': + $wc_payment_tokens->create_payment_token_applepay( + $wc_order->get_customer_id(), + $token_id + ); + break; + case 'paypal': + default: + $wc_payment_tokens->create_payment_token_paypal( + $wc_order->get_customer_id(), + $token_id, + $payment_source->properties()->email_address ?? '' + ); + break; + } + } + } + }, + 10, + 2 + ); + + add_filter( 'woocommerce_paypal_payments_disable_add_payment_method', '__return_false' ); + add_filter( 'woocommerce_paypal_payments_should_render_card_custom_fields', '__return_false' ); + + add_action( + 'wp_enqueue_scripts', + function () use ( $c ) { + if ( ! is_user_logged_in() || ! ( $this->is_add_payment_method_page() || $this->is_subscription_change_payment_method_page() ) ) { + return; + } + + $module_url = $c->get( 'save-payment-methods.module.url' ); + wp_enqueue_script( + 'ppcp-add-payment-method', + untrailingslashit( $module_url ) . '/assets/js/add-payment-method.js', + array( 'jquery' ), + $c->get( 'ppcp.asset-version' ), + true + ); + + $api = $c->get( 'api.user-id-token' ); + assert( $api instanceof UserIdToken ); + + try { + $target_customer_id = ''; + if ( is_user_logged_in() ) { + $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); + if ( ! $target_customer_id ) { + $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); + } + } + + $id_token = $api->id_token( $target_customer_id ); + + $settings = $c->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + + $verification_method = + $settings->has( '3d_secure_contingency' ) + ? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) ) + : ''; + + $change_payment_method = wc_clean( wp_unslash( $_GET['change_payment_method'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification + + wp_localize_script( + 'ppcp-add-payment-method', + 'ppcp_add_payment_method', + array( + 'client_id' => $c->get( 'button.client_id' ), + 'merchant_id' => $c->get( 'api.merchant_id' ), + 'id_token' => $id_token, + 'payment_methods_page' => wc_get_account_endpoint_url( 'payment-methods' ), + 'view_subscriptions_page' => wc_get_account_endpoint_url( 'view-subscription' ), + 'is_subscription_change_payment_page' => $this->is_subscription_change_payment_method_page(), + 'subscription_id_to_change_payment' => $this->is_subscription_change_payment_method_page() ? (int) $change_payment_method : 0, + 'error_message' => __( 'Could not save payment method.', 'woocommerce-paypal-payments' ), + 'verification_method' => $verification_method, + 'ajax' => array( + 'create_setup_token' => array( + 'endpoint' => \WC_AJAX::get_endpoint( CreateSetupToken::ENDPOINT ), + 'nonce' => wp_create_nonce( CreateSetupToken::nonce() ), + ), + 'create_payment_token' => array( + 'endpoint' => \WC_AJAX::get_endpoint( CreatePaymentToken::ENDPOINT ), + 'nonce' => wp_create_nonce( CreatePaymentToken::nonce() ), + ), + 'subscription_change_payment_method' => array( + 'endpoint' => \WC_AJAX::get_endpoint( SubscriptionChangePaymentMethod::ENDPOINT ), + 'nonce' => wp_create_nonce( SubscriptionChangePaymentMethod::nonce() ), + ), + ), + 'labels' => array( + 'error' => array( + 'generic' => __( + 'Something went wrong. Please try again or choose another payment source.', + 'woocommerce-paypal-payments' + ), + ), + ), + ) + ); + } catch ( RuntimeException $exception ) { + $logger = $c->get( 'woocommerce.logger.woocommerce' ); + assert( $logger instanceof LoggerInterface ); + + $error = $exception->getMessage(); + if ( is_a( $exception, PayPalApiException::class ) ) { + $error = $exception->get_details( $error ); + } + + $logger->error( $error ); } } + ); - $id_token = $api->id_token( $target_customer_id ); + add_action( + 'woocommerce_add_payment_method_form_bottom', + function () { + if ( ! is_user_logged_in() || ! is_add_payment_method_page() ) { + return; + } - $settings = $c->get( 'wcgateway.settings' ); - assert( $settings instanceof Settings ); - - $verification_method = - $settings->has( '3d_secure_contingency' ) - ? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) ) - : ''; - - $change_payment_method = wc_clean( wp_unslash( $_GET['change_payment_method'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification - - wp_localize_script( - 'ppcp-add-payment-method', - 'ppcp_add_payment_method', - array( - 'client_id' => $c->get( 'button.client_id' ), - 'merchant_id' => $c->get( 'api.merchant_id' ), - 'id_token' => $id_token, - 'payment_methods_page' => wc_get_account_endpoint_url( 'payment-methods' ), - 'view_subscriptions_page' => wc_get_account_endpoint_url( 'view-subscription' ), - 'is_subscription_change_payment_page' => $this->is_subscription_change_payment_method_page(), - 'subscription_id_to_change_payment' => $this->is_subscription_change_payment_method_page() ? (int) $change_payment_method : 0, - 'error_message' => __( 'Could not save payment method.', 'woocommerce-paypal-payments' ), - 'verification_method' => $verification_method, - 'ajax' => array( - 'create_setup_token' => array( - 'endpoint' => \WC_AJAX::get_endpoint( CreateSetupToken::ENDPOINT ), - 'nonce' => wp_create_nonce( CreateSetupToken::nonce() ), - ), - 'create_payment_token' => array( - 'endpoint' => \WC_AJAX::get_endpoint( CreatePaymentToken::ENDPOINT ), - 'nonce' => wp_create_nonce( CreatePaymentToken::nonce() ), - ), - 'subscription_change_payment_method' => array( - 'endpoint' => \WC_AJAX::get_endpoint( SubscriptionChangePaymentMethod::ENDPOINT ), - 'nonce' => wp_create_nonce( SubscriptionChangePaymentMethod::nonce() ), - ), - ), - 'labels' => array( - 'error' => array( - 'generic' => __( - 'Something went wrong. Please try again or choose another payment source.', - 'woocommerce-paypal-payments' - ), - ), - ), - ) - ); - } catch ( RuntimeException $exception ) { - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - assert( $logger instanceof LoggerInterface ); - - $error = $exception->getMessage(); - if ( is_a( $exception, PayPalApiException::class ) ) { - $error = $exception->get_details( $error ); + echo '
'; } + ); - $logger->error( $error ); - } - } - ); + add_action( + 'wc_ajax_' . CreateSetupToken::ENDPOINT, + static function () use ( $c ) { + $endpoint = $c->get( 'save-payment-methods.endpoint.create-setup-token' ); + assert( $endpoint instanceof CreateSetupToken ); - add_action( - 'woocommerce_add_payment_method_form_bottom', - function () use ( $c ) { - if ( ! is_user_logged_in() || ! is_add_payment_method_page() || ! self::vault_enabled( $c ) ) { - return; - } - - echo '
'; - } - ); - - add_action( - 'wc_ajax_' . CreateSetupToken::ENDPOINT, - static function () use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - $endpoint = $c->get( 'save-payment-methods.endpoint.create-setup-token' ); - assert( $endpoint instanceof CreateSetupToken ); - - $endpoint->handle_request(); - } - ); - - add_action( - 'wc_ajax_' . CreatePaymentToken::ENDPOINT, - static function () use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - $endpoint = $c->get( 'save-payment-methods.endpoint.create-payment-token' ); - assert( $endpoint instanceof CreatePaymentToken ); - - $endpoint->handle_request(); - } - ); - - add_action( - 'wc_ajax_' . CreatePaymentTokenForGuest::ENDPOINT, - static function () use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - $endpoint = $c->get( 'save-payment-methods.endpoint.create-payment-token-for-guest' ); - assert( $endpoint instanceof CreatePaymentTokenForGuest ); - - $endpoint->handle_request(); - } - ); - - add_action( - 'woocommerce_paypal_payments_before_delete_payment_token', - function( string $token_id ) use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - try { - $endpoint = $c->get( 'api.endpoint.payment-tokens' ); - assert( $endpoint instanceof PaymentTokensEndpoint ); - - $endpoint->delete( $token_id ); - } catch ( RuntimeException $exception ) { - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - assert( $logger instanceof LoggerInterface ); - - $error = $exception->getMessage(); - if ( is_a( $exception, PayPalApiException::class ) ) { - $error = $exception->get_details( $error ); + $endpoint->handle_request(); } + ); - $logger->error( $error ); - } - } - ); + add_action( + 'wc_ajax_' . CreatePaymentToken::ENDPOINT, + static function () use ( $c ) { + $endpoint = $c->get( 'save-payment-methods.endpoint.create-payment-token' ); + assert( $endpoint instanceof CreatePaymentToken ); - add_filter( - 'woocommerce_paypal_payments_credit_card_gateway_supports', - function( array $supports ) use ( $c ): array { - if ( ! self::vault_enabled( $c ) ) { - return $supports; - } - $supports[] = 'tokenization'; - $supports[] = 'add_payment_method'; + $endpoint->handle_request(); + } + ); - return $supports; - } - ); + add_action( + 'wc_ajax_' . CreatePaymentTokenForGuest::ENDPOINT, + static function () use ( $c ) { + $endpoint = $c->get( 'save-payment-methods.endpoint.create-payment-token-for-guest' ); + assert( $endpoint instanceof CreatePaymentTokenForGuest ); - add_filter( - 'woocommerce_paypal_payments_save_payment_methods_eligible', - function( bool $value ) use ( $c ): bool { - if ( ! self::vault_enabled( $c ) ) { - return $value; - } - return true; + $endpoint->handle_request(); + } + ); + + add_action( + 'woocommerce_paypal_payments_before_delete_payment_token', + function( string $token_id ) use ( $c ) { + try { + $endpoint = $c->get( 'api.endpoint.payment-tokens' ); + assert( $endpoint instanceof PaymentTokensEndpoint ); + + $endpoint->delete( $token_id ); + } catch ( RuntimeException $exception ) { + $logger = $c->get( 'woocommerce.logger.woocommerce' ); + assert( $logger instanceof LoggerInterface ); + + $error = $exception->getMessage(); + if ( is_a( $exception, PayPalApiException::class ) ) { + $error = $exception->get_details( $error ); + } + + $logger->error( $error ); + } + } + ); + + add_filter( + 'woocommerce_paypal_payments_credit_card_gateway_supports', + function( array $supports ) use ( $c ): array { + $settings = $c->get( 'wcgateway.settings' ); + assert( $settings instanceof ContainerInterface ); + + if ( $settings->has( 'vault_enabled_dcc' ) && $settings->get( 'vault_enabled_dcc' ) ) { + $supports[] = 'tokenization'; + $supports[] = 'add_payment_method'; + } + + return $supports; + } + ); + + add_filter( + 'woocommerce_paypal_payments_save_payment_methods_eligible', + function() { + return true; + } + ); } ); @@ -502,24 +488,4 @@ class SavePaymentMethodsModule implements ServiceModule, ExtendingModule, Execut return $localized_script_data; } - - /** - * Checks whether the vault functionality is enabled based on configuration settings. - * - * @param ContainerInterface $container The dependency injection container from which settings can be retrieved. - * - * @return bool Returns true if either 'vault_enabled' or 'vault_enabled_dcc' settings are enabled; otherwise, false. - */ - private static function vault_enabled( ContainerInterface $container ): bool { - $settings = $container->get( 'wcgateway.settings' ); - assert( $settings instanceof Settings ); - - if ( - ( ! $settings->has( 'vault_enabled' ) || ! $settings->get( 'vault_enabled' ) ) - && ( ! $settings->has( 'vault_enabled_dcc' ) || ! $settings->get( 'vault_enabled_dcc' ) ) - ) { - return false; - } - return true; - } } diff --git a/modules/ppcp-settings/images/logo-paypal.svg b/modules/ppcp-settings/images/logo-paypal.svg deleted file mode 100644 index 93109cac9..000000000 --- a/modules/ppcp-settings/images/logo-paypal.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/modules/ppcp-settings/package.json b/modules/ppcp-settings/package.json index 47e69347c..5cdb7be5c 100644 --- a/modules/ppcp-settings/package.json +++ b/modules/ppcp-settings/package.json @@ -9,6 +9,7 @@ "devDependencies": { "@wordpress/data": "^10.10.0", "@wordpress/data-controls": "^4.10.0", + "@wordpress/icons": "^10.14.0", "@wordpress/scripts": "^30.3.0", "classnames": "^2.5.1" }, diff --git a/modules/ppcp-settings/resources/css/_mixins.scss b/modules/ppcp-settings/resources/css/_mixins.scss index d2fac6cd2..8c83eb436 100644 --- a/modules/ppcp-settings/resources/css/_mixins.scss +++ b/modules/ppcp-settings/resources/css/_mixins.scss @@ -43,3 +43,14 @@ display: flex; gap: $gap; } + +@mixin disabled-state($control-type) { + .components-#{$control-type}-control.is-disabled { + .components-#{$control-type}-control__input, + .components-#{$control-type}-control__label, + .components-base-control__help { + opacity: 0.3; + cursor: default; + } + } +} diff --git a/modules/ppcp-settings/resources/css/_variables.scss b/modules/ppcp-settings/resources/css/_variables.scss index 10f427ea9..53483adb6 100644 --- a/modules/ppcp-settings/resources/css/_variables.scss +++ b/modules/ppcp-settings/resources/css/_variables.scss @@ -54,4 +54,11 @@ $card-vertical-gap: 48px; --color-gray-200: #{$color-gray-200}; --color-gray-100: #{$color-gray-100}; --color-gradient-dark: #{$color-gradient-dark}; + + --color-preview-background: #FAF8F5; + --color-separators: #{$color-gray-200}; + --color-text-title: #{$color-gray-900}; + --color-text-main: #{$color-text-text}; + --color-text-teriary: #{$color-text-tertiary}; + --color-text-description: #{$color-gray-700}; } diff --git a/modules/ppcp-settings/resources/css/components/_reusable.scss b/modules/ppcp-settings/resources/css/components/_reusable.scss new file mode 100644 index 000000000..2de887f57 --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/_reusable.scss @@ -0,0 +1,20 @@ +@import "./reusable-components/payment-method-item"; +@import './reusable-components/accordion-section'; +@import './reusable-components/badge-box'; +@import './reusable-components/busy-state'; +@import './reusable-components/button'; +@import './reusable-components/fields'; +@import './reusable-components/hstack'; +@import './reusable-components/navigation'; +@import './reusable-components/onboarding-header'; +@import './reusable-components/payment-method-icons'; +@import './reusable-components/select-box'; +@import './reusable-components/separator'; +@import './reusable-components/settings-block'; +@import './reusable-components/settings-card'; +@import './reusable-components/settings-toggle-block'; +@import './reusable-components/settings-wrapper'; +@import './reusable-components/spinner-overlay'; +@import './reusable-components/tab-navigation'; +@import './reusable-components/title-badge'; +@import './reusable-components/welcome-docs'; diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_fields.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_fields.scss index 1a9cf102c..195367dfb 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_fields.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_fields.scss @@ -1,27 +1,96 @@ -.ppcp-r { +.ppcp-r__radio-value { + @include hide-input-field; - &__radio-value { - @include hide-input-field; + &:checked + .ppcp-r__radio-presentation { + position: relative; - &:checked + .ppcp-r__radio-presentation { - position: relative; + &::before { + content: ''; + width: 12px; + height: 12px; + border-radius: 12px; + background-color: $color-blueberry; + display: block; + position: absolute; + transform: translate(-50%, -50%); + left: 50%; + top: 50%; + } + } +} - &::before { - content: ''; - width: 12px; - height: 12px; - border-radius: 12px; - background-color: $color-blueberry; - display: block; - position: absolute; - transform: translate(-50%, -50%); - left: 50%; - top: 50%; - } +.ppcp-r__radio-presentation { + @include fake-input-field(20px); +} + +.ppcp-r__checkbox-presentation { + @include fake-input-field(2px); +} + +.ppcp-r__radio-wrapper { + display: flex; + gap: 18px; + align-items: center; + position: relative; + + label { + @include font(13, 20, 400); + color: $color-gray-800; + } +} + +.ppcp-r__radio-description { + @include font(13, 20, 400); + margin: 0; + color: $color-gray-800; +} + +.ppcp-r__radio-content-additional { + padding-left: 38px; + padding-top: 18px; +} + + +.ppcp-r-app { + @include disabled-state('base'); + @include disabled-state('checkbox'); + + .components-base-control__label { + @include font(13, 16, 600); + color: $color-gray-900; + text-transform: none; + } + + .components-base-control__input { + border: 1px solid $color-gray-700; + border-radius: 2px; + box-shadow: none; + + &:focus { + border-color: $color-blueberry; } } - &__checkbox { + .components-base-control__help { + margin-bottom: 0; + } + + // Text input fields. + input[type='text'] { + @include font(14, 20, 400); + @include primaryFont; + padding: 7px 11px; + border-radius: 2px; + } + + // Select lists. + select { + @include font(14, 20, 400); + padding: 7px 27px 7px 11px; + } + + // Checkboxes. + .components-checkbox-control { position: relative; input { @@ -30,7 +99,7 @@ &:checked { background-color: $color-blueberry; - border-color:$color-blueberry; + border-color: $color-blueberry; } } @@ -43,78 +112,17 @@ } } - &__radio-presentation { - @include fake-input-field(20px); + // Custom styles. + .components-form-toggle.is-checked > .components-form-toggle__track { + background-color: $color-blueberry; } - &__checkbox-presentation { - @include fake-input-field(2px); - } - - &__radio-wrapper { - display: flex; - gap: 18px; - align-items: center; - position: relative; - - label { - @include font(13, 20, 400); - color: $color-gray-800; - } - } - - &__radio-description { - @include font(13, 20, 400); - margin: 0; - color: $color-gray-800; - } - - &__radio-content-additional { - padding-left: 38px; - padding-top: 18px; - } -} - -.components-base-control { - &__label { - color: $color-gray-900; - @include font(13, 16, 600); - text-transform: none; - } - - &__input { - border: 1px solid $color-gray-700; - border-radius: 2px; - box-shadow: none; - - &:focus { - border-color: $color-blueberry; + .ppcp-r-vertical-text-control { + .components-base-control__field { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; } } } - - -input[type='text'] { - padding: 7px 11px; - @include font(14, 20, 400); - @include primaryFont; - border-radius: 2px; -} - -select { - padding: 7px 27px 7px 11px; - @include font(14, 20, 400); -} - -.components-form-toggle.is-checked > .components-form-toggle__track { - background-color: $color-blueberry; -} - -.ppcp-r-vertical-text-control { - .components-base-control__field { - display: flex; - flex-direction: column; - gap: 0; - margin: 0; - } -} diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_hstack.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_hstack.scss new file mode 100644 index 000000000..e55584d1c --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_hstack.scss @@ -0,0 +1,20 @@ +.components-flex { + display: flex; + -webkit-box-align: stretch; + align-items: stretch; + flex-direction: column; + -webkit-box-pack: center; + justify-content: center; + + .components-h-stack { + flex-direction: row; + justify-content: flex-start; + gap: 32px; + } + + // Fix layout for checkboxes inside a flex-stack. + .components-checkbox-control >.components-base-control__field > .components-flex { + flex-direction: row; + gap: 12px; + } +} diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_payment-method-modal.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_payment-method-modal.scss index df0cde379..1efe54236 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_payment-method-modal.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_payment-method-modal.scss @@ -119,8 +119,6 @@ background-color: $color-white; &::before { - transform: translate(3px, 3px); - border-width: 6px; border-color: $color-blueberry; } } diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_settings-block.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_settings-block.scss new file mode 100644 index 000000000..8888bf2c0 --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_settings-block.scss @@ -0,0 +1,194 @@ +/* + Styles the `SettingsBlock` and all its derived components. + */ +.ppcp-r-settings-block { + display: flex; + flex-direction: column; + gap: var(--block-item-gap, 16px); + + &.ppcp-r-settings-block__input, + &.ppcp-r-settings-block__select { + gap: 6px 0; + } + + .ppcp-r-settings-block__header { + display: flex; + flex-direction: column; + gap: 6px; + + &:not(:last-child) { + padding-bottom: var(--block-header-gap, 6px); + } + } + + .ppcp-r-settings-block__title { + @include font(11, 22, 600); + color: var(--color-text-title); + display: block; + text-transform: uppercase; + + &.style-alt { + @include font(14, 16, 600); + text-transform: none; + } + + &.style-big { + @include font(16, 20, 600); + } + + .ppcp-r-title-badge { + text-transform: none; + margin-left: 6px; + } + } + + .ppcp-r-settings-block__title-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + } + + &.ppcp-r-settings-block__feature { + .ppcp-r-settings-block__title { + @include font(13, 20, 600); + color: var(--color-text-main); + text-transform: none; + } + + .ppcp-r-settings-block__feature__description { + @include font(13, 20, 400); + color: var(--color-text-description); + } + } + + &.ppcp-r-settings-block__toggle { + display: flex; + flex-direction: row; + + .ppcp-r-settings-block__title { + @include font(13, 20, 400); + color: var(--color-text-main); + text-transform: none; + } + } + + .ppcp-r-settings-block__description { + @include font(13, 20, 400); + margin: 0; + color: var(--color-text-description); + + &:not(:last-child) { + padding-bottom: 1em; + } + + a { + color: var(--color-blueberry); + } + + strong { + color: var(--color-gray-800); + } + } + + .ppcp-r-settings-block__supplementary-title-label { + @include font(13, 20, 400); + color: var(--color-text-teriary); + text-transform: none; + margin-left: 5px; + } + + .ppcp-r-settings-block__action { + display: flex; + align-items: center; + + .components-flex { + row-gap: 0; + } + } + + + .ppcp-r-settings-block:not(.no-gap) { + margin-top: var(--block-separator-gap, 32px); + padding-top: var(--block-separator-gap, 32px); + border-top: 1px solid var(--color-gray-200); + } + + // Types + &--toggle-content { + &.ppcp-r-settings-block--content-visible { + .ppcp-r-settings-block__toggle-content { + transform: rotate(180deg); + } + } + + .ppcp-r-settings-block__header { + user-select: none; + + &:hover { + cursor: pointer; + } + } + } + + &--sandbox-connected { + .ppcp-r-settings-block__content { + margin-top: 24px; + } + + .ppcp-r-connection-status__data { + margin-bottom: 20px; + } + } + + &--connect-sandbox { + button.components-button { + @include small-button; + } + + .ppcp-r__radio-content-additional { + @include vertical-layout-event-gap(24px); + align-items: flex-start; + + .ppcp-r-vertical-text-control, + input[type='text'] { + width: 100%; + } + } + } +} + +.ppcp-r-settings-block { + &--order-intent, + &--save-payment-methods { + @include vertical-layout-event-gap(24px); + + > .ppcp-r-settings-block__content { + @include vertical-layout-event-gap(24px); + } + } +} + +.ppcp-r-settings-block--toggle-content { + .ppcp-r-settings-block__content { + margin-top: 32px; + } +} + +.ppcp-r-settings-block__button { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 50px; +} + +.ppcp-r-settings-block__accordion { + > .ppcp-r-accordion { + width: 100%; + + .ppcp-r-accordion__toggler { + width: 100%; + margin: 0; + text-align: unset; + } + } +} diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_settings-card.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_settings-card.scss new file mode 100644 index 000000000..634e3a8b7 --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_settings-card.scss @@ -0,0 +1,63 @@ +/* + Styles the `SettingsCard` layout component. + + This is a 2-column row that displays a title + description on the + left side, and a "card" with settings content on the right side. + */ +.ppcp-r-settings-card { + + // -- Theming + + --card-width-header: 100%; + --card-width-content: 100%; + --card-gap: 0; + --card-layout: block; + + @media screen and (min-width: 960px) { + --card-width-header: 280px; + --card-width-content: 610px; + --card-gap: 48px; + --card-layout: flex; + } + + // -- Styling + + display: var(--card-layout); + gap: var(--card-gap); + margin: 0 0 var(--card-gap) 0; + + .ppcp-r-settings-card__header { + display: var(--card-layout); + width: var(--card-width-header); + flex: 0 0 var(--card-width-header); + gap: 18px; + padding-bottom: 18px; + } + + .ppcp-r-settings-card__content-wrapper { + display: flex; + flex-direction: column; + gap: 24px; + } + + .ppcp-r-settings-card__content { + flex: 1; + max-width: var(--card-width-content); + border: 1px solid var(--color-gray-200); + border-radius: 4px; + padding: 24px; + } + + .ppcp-r-settings-card__title { + @include font(13, 24, 600); + color: var(--color-text-main); + margin: 0 0 4px 0; + display: block; + } + + .ppcp-r-settings-card__description { + @include font(13, 20, 400); + color: var(--color-text-teriary); + margin: 0; + } +} diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_settings-wrapper.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_settings-wrapper.scss index a13df6e77..342e4be1d 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_settings-wrapper.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_settings-wrapper.scss @@ -26,60 +26,4 @@ border-bottom: 1px solid $color-gray-200; } } - - &-settings-card { - @media screen and (min-width: 960px) { - display: flex; - gap: 48px; - } - - @media screen and (max-width: 480px) { - padding: 24px; - } - - &__content-wrapper { - display: flex; - flex-direction: column; - gap: 24px; - } - - &__header { - display: flex; - gap: 18px; - padding-bottom: 18px; - border-bottom: 2px solid $color-gray-700; - margin-bottom: 32px; - - @media screen and (min-width: 960px) { - width: 280px; - flex-shrink: 0; - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; - } - } - - &__content { - border: 1px solid $color-gray-200; - border-radius: 4px; - padding: 24px; - @media screen and (min-width: 960px) { - flex: 1; - } - } - - &__title { - @include font(13, 24, 600); - color: $color-text-text; - margin: 0 0 4px 0; - display: block; - } - - - &__description { - @include font(13, 20, 400); - color: $color-text-tertiary; - margin: 0; - } - } } diff --git a/modules/ppcp-settings/resources/css/components/screens/_settings.scss b/modules/ppcp-settings/resources/css/components/screens/_settings.scss index b98a5f7e1..0b8802138 100644 --- a/modules/ppcp-settings/resources/css/components/screens/_settings.scss +++ b/modules/ppcp-settings/resources/css/components/screens/_settings.scss @@ -1,4 +1,7 @@ -@import "./settings/block-accordion"; +@import './settings/input'; +@import './settings/connection-status'; +@import './settings/tab-styling'; +@import './settings/tab-paylater-configurator'; // Container and Tab Settings .ppcp-r-tabs.settings, @@ -11,10 +14,6 @@ } // Todo List and Feature Items -.ppcp-r-tab-overview-todo { - margin: 0 0 48px 0; -} - .ppcp-r-todo-item { position: relative; display: flex; @@ -22,6 +21,16 @@ gap: 18px; width: 100%; + &:hover { + cursor: pointer; + + .ppcp-r-todo-item__inner { + .ppcp-r-todo-item__description { + color: $color-text-text; + } + } + } + &:not(:last-child) { border-bottom: 1px solid $color-gray-400; padding-bottom: 16px; @@ -66,6 +75,14 @@ @include font(13, 20, 400); color: $color-blueberry; } + + &__icon { + border: 1px dashed #949494; + background: #fff; + border-radius: 50%; + width: 24px; + height: 24px; + } } .ppcp-r-feature-item { @@ -101,90 +118,8 @@ span { font-weight: 500; } - } -} -// Connection Status -.ppcp-r-connection-status { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 12px; - - &__status-status { - margin: 0 0 8px 0; - - strong { - @include font(14, 24, 700); - color: $color-black; - } - } - - &__show-all-data { - margin-left: 12px; - } - - &__status-label { - @include font(11, 22, 600); - color: $color-gray-900; - display: block; - text-transform: uppercase; - } - - &__status-value { - @include font(13, 26, 400); - color: $color-text-tertiary; - } - - &__data { - display: flex; - flex-direction: column; - gap: 12px; - } - - &__status-toggle--toggled { - .ppcp-r-connection-status__show-all-data { - transform: rotate(180deg); - } - } - - &__status-row { - display: flex; - flex-direction: column; - * { - user-select: none; - } - strong { - @include font(14, 24, 600); - color: $color-gray-800; - margin-right: 12px; - white-space: nowrap; - } - - .ppcp-r-connection-status__status-toggle { - line-height: 0; - } - &--first { - &:hover { - cursor: pointer; - } - } - } - - @media screen and (max-width: 767px) { - flex-wrap: wrap; - &__status { - width: 100%; - } - &__status-row { - flex-wrap: wrap; - strong { - width: 100%; - } - span { - word-break: break-all; - } - } + margin-top: 24px; } } @@ -230,306 +165,3 @@ gap: 48px; } -// Settings Card and Block Styles -.ppcp-r-settings-card__content { - > .ppcp-r-settings-block { - &:not(:last-child) { - border-bottom: 1px solid $color-divider; - } - } -} - -.ppcp-r-settings-block { - display: flex; - flex-direction: column; - gap: 16px 0; - - &.ppcp-r-settings-block__input, - &.ppcp-r-settings-block__select { - gap: 6px 0; - } - - .ppcp-r-settings-block__header { - display: flex; - flex-direction: column; - gap: 6px; - - &:not(:last-child):not(.ppcp-r-settings-block--accordion__header) { - padding-bottom: 6px; - } - } - - .ppcp-r-settings-block__title { - @include font(11, 22, 600); - color: $color-gray-900; - display: block; - text-transform: uppercase; - - .ppcp-r-title-badge { - text-transform: none; - margin-left: 6px; - } - } - - .ppcp-r-settings-block__title-wrapper { - display: flex; - justify-content: space-between; - align-items: center; - } - - &.ppcp-r-settings-block__feature { - .ppcp-r-settings-block__title { - @include font(13, 20, 600); - color: $color-text-text; - text-transform: none; - } - - .ppcp-r-settings-block__feature__description { - color: $color-gray-700; - @include font(13, 20, 400); - } - } - - &.ppcp-r-settings-block__toggle { - display: flex; - flex-direction: row; - - .ppcp-r-settings-block__title { - color: $color-text-text; - @include font(13, 20, 400); - text-transform: none; - } - } - - .ppcp-r-settings-block__description { - margin: 0; - @include font(13, 20, 400); - color: $color-gray-800; - - &:not(:last-child) { - padding-bottom: 1em; - } - - a { - color: $color-blueberry; - } - - strong { - color: $color-gray-800; - } - } - - .ppcp-r-settings-block__supplementary-title-label { - @include font(13, 20, 400); - color: $color-text-tertiary; - text-transform: none; - margin-left: 5px; - } - - // Types - &--toggle-content { - &.ppcp-r-settings-block--content-visible { - .ppcp-r-settings-block__toggle-content { - transform: rotate(180deg); - } - } - - .ppcp-r-settings-block__header { - user-select: none; - - &:hover { - cursor: pointer; - } - } - } - - &--sandbox-connected { - .ppcp-r-settings-block__content { - margin-top: 24px; - } - - .ppcp-r-connection-status__data { - margin-bottom: 20px; - } - } - - &--connect-sandbox { - button.components-button { - @include small-button; - } - - .ppcp-r__radio-content-additional { - .ppcp-r-vertical-text-control { - width: 100%; - } - - @include vertical-layout-event-gap(24px); - align-items: flex-start; - - input[type='text'] { - width: 100%; - } - } - } - - &--troubleshooting, - &--settings { - > .ppcp-r-settings-block__content > *:not(:last-child) { - padding-bottom: 32px; - margin-bottom: 32px; - border-bottom: 1px solid $color-gray-500; - } - } - - // Fields - input[type='text'] { - border-color: $color-gray-700; - width: 100%; - max-width: 100%; - color: $color-gray-800; - - &::placeholder { - color: $color-gray-700; - } - } - - // MultiSelect control - .ppcp-r { - &__radio-wrapper { - align-items: flex-start; - gap: 12px; - } - - &__radio-content { - display: flex; - flex-direction: column; - gap: 4px; - - label { - font-weight: 600; - } - } - - &__radio-content-additional { - padding-left: 32px; - } - - // Select control styles - &__control { - border-radius: 2px; - border-color: $color-gray-700; - min-height: auto; - padding: 0; - } - - &__input-container { - padding: 0; - margin: 0; - } - - &__value-container { - padding: 0 0 0 7px; - } - - &__indicator { - padding: 5px; - } - - &__indicator-separator { - display: none; - } - - &__value-container--has-value { - .ppcp-r__single-value { - color: $color-gray-800; - } - } - - &__placeholder, - &__single-value { - @include font(13, 20, 400); - } - - &__option { - &--is-selected { - background-color: $color-gray-200; - } - } - } -} - -// Hooks table -.ppcp-r-table { - &__hooks-url { - width: 70%; - padding-right: 20%; - text-align: left; - vertical-align: top; - } - - &__hooks-events { - vertical-align: top; - text-align: left; - width: 40%; - - span { - display: block; - } - } - - td.ppcp-r-table__hooks-url, - td.ppcp-r-table__hooks-events { - padding-top: 12px; - color: $color-gray-800; - @include font(14, 20, 400); - - span { - color: inherit; - @include font(14, 20, 400); - } - } - - th.ppcp-r-table__hooks-url, - th.ppcp-r-table__hooks-events { - @include font(14, 20, 700); - color: $color-gray-800; - border-bottom: 1px solid $color-gray-600; - padding-bottom: 4px; - } -} - -// Settings specific styles -.ppcp-r-settings-card--common-settings .ppcp-r-settings-card__content, -.ppcp-r-settings-card--expert-settings .ppcp-r-settings-card__content { - > .ppcp-r-settings-block { - &:not(:last-child) { - padding-bottom: 32px; - margin-bottom: 32px; - } - } -} - -.ppcp-r-settings-block { - &--order-intent, - &--save-payment-methods { - @include vertical-layout-event-gap(24px); - - > .ppcp-r-settings-block__content { - @include vertical-layout-event-gap(24px); - } - } -} - -.ppcp-r-settings-block--toggle-content { - .ppcp-r-settings-block__content { - margin-top: 32px; - } -} - -.ppcp-r-settings-block__button { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 50px; -} diff --git a/modules/ppcp-settings/resources/css/components/screens/overview/_tab-styling.scss b/modules/ppcp-settings/resources/css/components/screens/overview/_tab-styling.scss deleted file mode 100644 index c75d6a4c9..000000000 --- a/modules/ppcp-settings/resources/css/components/screens/overview/_tab-styling.scss +++ /dev/null @@ -1,121 +0,0 @@ -.ppcp-r-styling { - display: flex; - border: 1px solid $color-gray-200; - border-radius: 8px; - overflow: hidden; - - &__section:not(:last-child) { - border-bottom: 1px solid black; - padding-bottom: 24px; - margin-bottom: 28px; - border-bottom: 1px solid $color-gray-600; - } - - &__main-title { - @include font(14, 20, 600); - color: $color-gray-800; - margin: 0 0 8px 0; - display: block; - } - - &__description { - @include font(13, 20, 400); - color: $color-gray-800; - margin: 0 0 18px 0; - } - - &__settings { - width: 422px; - background-color: $color-white; - padding: 48px; - } - - &__preview { - width: calc(100% - 422px); - background-color: #FAF8F5; - display: flex; - align-items: center; - - &-inner { - width: 100%; - padding: 24px; - } - } - - &__section--rc { - .ppcp-r-styling__title { - @include font(13, 20, 600); - color: $color-black; - display: block; - margin: 0 0 18px 0; - } - } - - &__section--empty.ppcp-r-styling__section { - padding-bottom: 0; - margin-bottom: 0; - border-bottom: none; - } - - &__select { - label { - @include font(13, 16, 600); - color: $color-black; - margin: 0; - text-transform: none; - } - - select { - @include font(13, 20, 400); - } - } - - .ppcp-r__checkbox { - .components-checkbox-control { - &__label { - @include font(13, 20, 400); - color: $color-black; - } - } - - .components-flex { - gap: 12px; - } - } - - &__payment-method-checkboxes { - display: flex; - flex-direction: column; - gap: 24px; - } -} - -.ppcp-r { - &__horizontal-control { - .components-flex { - flex-direction: row; - justify-content: flex-start; - gap: 32px; - } - - - .components-radio-control { - &__option { - gap: 12px; - - input { - margin: 0; - } - - label { - @include font(13, 20, 400); - color: $color-black; - } - } - - input { - margin: 0; - } - } - } -} diff --git a/modules/ppcp-settings/resources/css/components/screens/settings/_block-accordion.scss b/modules/ppcp-settings/resources/css/components/screens/settings/_block-accordion.scss deleted file mode 100644 index c77a3eb91..000000000 --- a/modules/ppcp-settings/resources/css/components/screens/settings/_block-accordion.scss +++ /dev/null @@ -1,38 +0,0 @@ -.ppcp-r-settings-block__accordion { - > .ppcp-r-accordion { - width: 100%; - - .ppcp-r-accordion__toggler { - width: 100%; - margin: 0; - text-align: unset; - } - } - - &.ppcp-r-settings-block { - gap: 0; - - .ppcp-r-settings-block__title { - @include font(13, 20, 600); - color: $color-text-text; - text-transform: none; - } - - .ppcp-r-settings-block--accordion__title { - @include font(14, 20, 600); - } - - .ppcp-r-settings-block--accordion__description { - color: $color-gray-700; - @include font(13, 20, 400); - } - - .ppcp-r-settings-block:not(:last-child) { - &:not(.ppcp-r__radio-content-additional .ppcp-r-settings-block) { - padding-bottom: 32px; - margin-bottom: 32px; - border-bottom: 1px solid $color-divider; - } - } - } -} diff --git a/modules/ppcp-settings/resources/css/components/screens/settings/_connection-status.scss b/modules/ppcp-settings/resources/css/components/screens/settings/_connection-status.scss new file mode 100644 index 000000000..2bf1417e6 --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/screens/settings/_connection-status.scss @@ -0,0 +1,78 @@ +// Connection Status +.ppcp-r-connection-status { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + + &__status-status { + margin: 0 0 8px 0; + + strong { + @include font(14, 24, 700); + color: $color-black; + } + } + + &__status-label { + @include font(11, 22, 600); + color: $color-gray-900; + display: block; + text-transform: uppercase; + } + + &__status-value { + @include font(13, 26, 400); + color: $color-text-tertiary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__data { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + } + + &__status-toggle--toggled { + .ppcp-r-connection-status__show-all-data { + transform: rotate(180deg); + } + } + + &__status-row { + display: flex; + flex-direction: column; + + strong { + @include font(14, 24, 600); + color: $color-gray-800; + margin-right: 12px; + white-space: nowrap; + } + + .ppcp-r-connection-status__status-toggle { + line-height: 0; + } + } + + @media screen and (max-width: 767px) { + flex-wrap: wrap; + &__status { + width: 100%; + } + &__status-row { + flex-wrap: wrap; + + strong { + width: 100%; + } + + span { + word-break: break-all; + } + } + } +} diff --git a/modules/ppcp-settings/resources/css/components/screens/settings/_input.scss b/modules/ppcp-settings/resources/css/components/screens/settings/_input.scss new file mode 100644 index 000000000..d71a52150 --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/screens/settings/_input.scss @@ -0,0 +1,77 @@ +// Fields +.ppcp-r { + input[type='text'] { + border-color: $color-gray-700; + width: 100%; + max-width: 100%; + color: $color-gray-800; + + &::placeholder { + color: $color-gray-700; + } + } +} + +// MultiSelect control +.ppcp-r { + &__radio-wrapper { + align-items: flex-start; + gap: 12px; + } + + &__radio-content { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-weight: 600; + } + } + + &__radio-content-additional { + padding-left: 32px; + } + + // Select control styles + &__control { + border-radius: 2px; + border-color: $color-gray-700; + min-height: auto; + padding: 0; + } + + &__input-container { + padding: 0; + margin: 0; + } + + &__value-container { + padding: 0 0 0 7px; + } + + &__indicator { + padding: 5px; + } + + &__indicator-separator { + display: none; + } + + &__value-container--has-value { + .ppcp-r__single-value { + color: $color-gray-800; + } + } + + &__placeholder, + &__single-value { + @include font(13, 20, 400); + } + + &__option { + &--is-selected { + background-color: $color-gray-200; + } + } +} diff --git a/modules/ppcp-settings/resources/css/components/screens/settings/_tab-paylater-configurator.scss b/modules/ppcp-settings/resources/css/components/screens/settings/_tab-paylater-configurator.scss new file mode 100644 index 000000000..d4c10d6c3 --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/screens/settings/_tab-paylater-configurator.scss @@ -0,0 +1,100 @@ +.ppcp-r-paylater-configurator { + display: flex; + border: 1px solid var(--color-separators); + border-radius: 8px; + overflow: hidden; + font-family: "PayPalPro", sans-serif; + -webkit-font-smoothing: antialiased; + + .css-1snxoyf.eolpigi0 { + margin: 0; + } + + #configurator-eligibleContainer.css-4nclxm.e1vy3g880 { + width: 100%; + max-width: 100%; + padding: 48px 0px 48px 48px; + + #configurator-controlPanelContainer.css-5urmrq.e1vy3g880 { + width: 374px; + padding-right: 48px; + } + + #configurator-previewSectionContainer.css-vojyxx.e1vy3g880 { + width: calc(100% - 374px); + + .css-7xkxom, .css-8tvj6u { + height: auto; + } + + .css-10nkerk.ej6n7t60 { + align-items: flex-start; + } + + .css-1sgwra0-svg-size_sm { + height: 1.2rem; + width: 1.2rem; + } + + .css-1vc34jy-handler { + height: 1.6rem; + width: 1.6rem; + } + + .css-8vwtr6-state { + height: 1.6rem; + } + } + } + + &__subheader, #configurator-controlPanelSubHeader { + color: var(--color-text-description); + margin: 0 0 18px 0; + } + + &__header, #configurator-controlPanelHeader, #configurator-previewSectionSubHeaderText.css-14ujlqd-text_body, .css-16jt5za-text_body { + @include font(16, 20, 600); + color: var(--color-text-title); + margin-bottom: 6px; + font-family: "PayPalPro", sans-serif; + -webkit-font-smoothing: antialiased; + } + + .css-1yo2lxy-text_body_strong { + color: var(--color-text-description); + margin: 0; + text-transform: none; + } + + .css-rok10q, .css-dfgbdq-text_body_strong { + margin-top: 0; + } + + &__publish-button { + display: none; + } + + .css-udzaps { + padding: 0px; + } + + .css-104jwuk, + .css-dpyjrq-text_body, + .css-1oxdnb3-dropdown_menu_button-text_field_value_sm-active, + .css-1wvwydd-dropdown_menu_button-text_field_value_sm-active-active, + .css-16jt5za-text_body, + .css-1caaugt-links_base-text_body_strong, + .css-dpyjrq-text_body, + &__subheader, + #configurator-controlPanelSubHeader, + .css-1yo2lxy-text_body_strong{ + @include font(13, 20, 400); + font-family: "PayPalPro", sans-serif; + -webkit-font-smoothing: antialiased; + } + + .css-1k9r7mv-text_body, .css-ra9ecy-text_body_strong { + font-family: "PayPalPro", sans-serif; + -webkit-font-smoothing: antialiased; + } +} diff --git a/modules/ppcp-settings/resources/css/components/screens/settings/_tab-styling.scss b/modules/ppcp-settings/resources/css/components/screens/settings/_tab-styling.scss new file mode 100644 index 000000000..8645e7dad --- /dev/null +++ b/modules/ppcp-settings/resources/css/components/screens/settings/_tab-styling.scss @@ -0,0 +1,96 @@ +.ppcp-r-styling { + --block-item-gap: 0; + --block-separator-gap: 24px; + --block-header-gap: 18px; + --panel-width: 422px; + --sticky-offset-top: 92px; // 32px admin-bar + 60px TopNavigation height + --preview-height-reduction: 236px; // 32px admin-bar + 60px TopNavigation height + 48px TopNavigation margin + 48px TabList height + 48px TabList margin + + display: flex; + border: 1px solid var(--color-separators); + border-radius: 8px; + + .ppcp-r-settings-block { + &.header-section { + margin-bottom: 6px + } + + &.location-selector { + position: sticky; + top: var(--sticky-offset-top); + background: var(--ppcp-color-app-bg); + z-index: 5; + padding: 16px 10px 8px; + margin: 0 -10px -8px; + + .section-content { + display: flex; + + & > .components-base-control:first-of-type { + width: 100%; + } + } + } + + // Select-fields have a smaller gap between the header and input field. + &.has-select { + --block-header-gap: 8px; + } + + // Above the payment methods is a slightly larger gap. + &.payment-methods { + --block-separator-gap: 28px; + } + + // It has no header; adjusts the gap to the control right above the tagline. + &.tagline { + --block-header-gap: 24px; + } + } + + /* The settings-panel (left side) */ + .settings-panel { + width: var(--panel-width); + padding: 48px; + + .ppcp-r-styling__section { + padding-bottom: 24px; + margin-bottom: 28px; + border-bottom: 1px solid var(--color-separators); + + &.no-gap, + &:last-child { + padding-bottom: 0; + margin-bottom: 0; + border-bottom-style: none; + } + } + + // Horizontal radio buttons have a width of 100px. + .components-radio-control__option { + min-width: 100px; + } + } + + /* The preview area (right side) */ + .preview-panel { + width: calc(100% - var(--panel-width)); + background-color: var(--color-preview-background); + z-index: 0; + + .preview-panel-inner { + position: sticky; + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + padding: 24px; + height: calc(100vh - var(--preview-height-reduction)); + top: var(--sticky-offset-top); + + // Disable interactions with the preview. + pointer-events: none; + user-select: none; + } + } +} diff --git a/modules/ppcp-settings/resources/css/style.scss b/modules/ppcp-settings/resources/css/style.scss index 56fe55a62..0875dcad5 100644 --- a/modules/ppcp-settings/resources/css/style.scss +++ b/modules/ppcp-settings/resources/css/style.scss @@ -3,26 +3,9 @@ #ppcp-settings-container { @import './global'; - @import './components/reusable-components/busy-state'; - @import './components/reusable-components/button'; - @import './components/reusable-components/separator'; - @import './components/reusable-components/onboarding-header'; - @import './components/reusable-components/settings-toggle-block'; - @import './components/reusable-components/payment-method-icons'; - @import "./components/reusable-components/payment-method-item"; - @import './components/reusable-components/settings-wrapper'; - @import './components/reusable-components/select-box'; - @import './components/reusable-components/tab-navigation'; - @import './components/reusable-components/navigation'; - @import './components/reusable-components/fields'; - @import './components/reusable-components/title-badge'; - @import './components/reusable-components/accordion-section'; - @import './components/reusable-components/badge-box'; - @import './components/reusable-components/spinner-overlay'; - @import './components/reusable-components/welcome-docs'; + @import './components/reusable'; @import './components/screens/onboarding'; @import './components/screens/settings'; - @import './components/screens/overview/tab-styling'; @import './components/app'; } diff --git a/modules/ppcp-settings/resources/js/App.js b/modules/ppcp-settings/resources/js/App.js deleted file mode 100644 index 33d0f94f4..000000000 --- a/modules/ppcp-settings/resources/js/App.js +++ /dev/null @@ -1,8 +0,0 @@ -import Settings from './Components/Screens/Settings'; -export function App() { - return ( -
- -
- ); -} diff --git a/modules/ppcp-settings/resources/js/Components/App.js b/modules/ppcp-settings/resources/js/Components/App.js new file mode 100644 index 000000000..6548f4b1e --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/App.js @@ -0,0 +1,69 @@ +import { useEffect, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import classNames from 'classnames'; +import { OnboardingHooks, CommonHooks } from '../data'; +import SpinnerOverlay from './ReusableComponents/SpinnerOverlay'; +import SendOnlyMessage from './Screens/SendOnlyMessage'; +import OnboardingScreen from './Screens/Onboarding'; +import SettingsScreen from './Screens/Settings'; +import { initStore as initSettingsStore } from '../data/settings-tab'; +import { useSettingsState } from '../data/settings-tab/hooks'; + +// Initialize the settings store +initSettingsStore(); + +const SettingsApp = () => { + const onboardingProgress = OnboardingHooks.useSteps(); + const { isReady: settingsIsReady } = useSettingsState(); + const { + isReady: merchantIsReady, + merchant: { isSendOnlyCountry }, + } = CommonHooks.useMerchantInfo(); + + // Disable the "Changes you made might not be saved" browser warning. + useEffect( () => { + const suppressBeforeUnload = ( event ) => { + event.stopImmediatePropagation(); + return undefined; + }; + window.addEventListener( 'beforeunload', suppressBeforeUnload ); + return () => { + window.removeEventListener( 'beforeunload', suppressBeforeUnload ); + }; + }, [] ); + + const wrapperClass = classNames( 'ppcp-r-app', { + loading: ! onboardingProgress.isReady || ! settingsIsReady, + } ); + + const Content = useMemo( () => { + if ( + ! onboardingProgress.isReady || + ! merchantIsReady || + ! settingsIsReady + ) { + return ( + + ); + } + if ( isSendOnlyCountry ) { + return ; + } + if ( ! onboardingProgress.completed ) { + return ; + } + return ; + }, [ + isSendOnlyCountry, + merchantIsReady, + onboardingProgress.completed, + onboardingProgress.isReady, + settingsIsReady, + ] ); + + return
{ Content }
; +}; + +export default SettingsApp; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/BadgeBox.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/BadgeBox.js index 24dc36134..337e5626c 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/BadgeBox.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/BadgeBox.js @@ -1,40 +1,62 @@ import data from '../../utils/data'; -const BadgeBox = ( props ) => { - const titleSize = - props.titleType && props.titleType === BADGE_BOX_TITLE_BIG - ? BADGE_BOX_TITLE_BIG - : BADGE_BOX_TITLE_SMALL; +const ImageBadge = ( { images } ) => { + if ( ! images || ! images.length ) { + return null; + } + + return ( + + + { images.map( ( badge ) => data().getImage( badge ) ) } + + + ); +}; + +// If `children` is not empty, it's output and wrapped in spaces. +const BadgeContent = ( { children } ) => { + if ( ! children ) { + return null; + } + return <> { children } ; +}; + +const BadgeBox = ( { + title, + textBadge, + imageBadge = [], + titleType = BADGE_BOX_TITLE_BIG, + description = '', +} ) => { + let titleSize = BADGE_BOX_TITLE_SMALL; + if ( BADGE_BOX_TITLE_BIG === titleType ) { + titleSize = BADGE_BOX_TITLE_BIG; + } const titleTextClassName = 'ppcp-r-badge-box__title-text ' + `ppcp-r-badge-box__title-text--${ titleSize }`; const titleBaseClassName = 'ppcp-r-badge-box__title'; - const titleClassName = props.imageBadge + const titleClassName = imageBadge.length ? `${ titleBaseClassName } ppcp-r-badge-box__title--has-image-badge` : titleBaseClassName; + return (
- { props.title } + { title } - { props.imageBadge && ( - - { props.imageBadge.map( ( badge ) => - data().getImage( badge ) - ) } - - ) } - - { props.textBadge } + + { textBadge }
- { props?.description && ( + { description && (

) } diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js index 959b71bfe..239a088b7 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js @@ -24,6 +24,7 @@ const BusyContext = createContext( false ); * @param {boolean} props.busySpinner - Allows disabling the spinner in busy-state. * @param {string} props.className - Additional class names for the wrapper. * @param {Function} props.onBusy - Callback to process child props when busy. + * @param {boolean} props.isBusy - Optional. Additional condition to determine if the component is busy. */ const BusyStateWrapper = ( { children, @@ -31,11 +32,12 @@ const BusyStateWrapper = ( { busySpinner = true, className = '', onBusy = () => ( { disabled: true } ), + isBusy = false, } ) => { - const { isBusy } = CommonHooks.useBusyState(); + const { isBusy: globalIsBusy } = CommonHooks.useBusyState(); const hasBusyParent = useContext( BusyContext ); - const isBusyComponent = isBusy && enabled; + const isBusyComponent = ( isBusy || globalIsBusy ) && enabled; const showSpinner = busySpinner && isBusyComponent && ! hasBusyParent; const wrapperClassName = classNames( 'ppcp-r-busy-wrapper', className, { diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/ConnectionInfo.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/ConnectionInfo.js index 5fbfdb1f1..201681a5f 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/ConnectionInfo.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/ConnectionInfo.js @@ -1,54 +1,35 @@ import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; +import { CommonHooks } from '../../data'; -const ConnectionInfo = ( { connectionStatusDataDefault } ) => { - const [ connectionData, setConnectionData ] = useState( { - ...connectionStatusDataDefault, - } ); - - const toggleStatusClassName = [ 'ppcp-r-connection-status__status-toggle' ]; - - if ( connectionData.showAllData ) { - toggleStatusClassName.push( - 'ppcp-r-connection-status__status-toggle--toggled' - ); - } +const ConnectionInfo = () => { + const { merchant } = CommonHooks.useMerchantInfo(); return (
-
- - { __( 'Merchant ID', 'woocommerce-paypal-payments' ) } - - - { connectionData.merchantId } - -
-
- - { __( 'Email address', 'woocommerce-paypal-payments' ) } - - - { connectionData.email } - -
-
- - { __( 'Client ID', 'woocommerce-paypal-payments' ) } - - - { connectionData.clientId } - -
+ + +
); }; export default ConnectionInfo; -export const connectionStatusDataDefault = { - connectionStatus: true, - showAllData: false, - email: 'bt_us@woocommerce.com', - merchantId: 'AT45V2DGMKLRY', - clientId: 'BAARTJLxtUNN4d2GMB6Eut3suMDYad72xQA-FntdIFuJ6FmFJITxAY8', -}; +const StatusRow = ( { label, value } ) => ( +
+ + { label } + + + { value } + +
+); diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Fields.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Fields.js index 74336951f..d979cffba 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Fields.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Fields.js @@ -1,107 +1,138 @@ import { CheckboxControl } from '@wordpress/components'; +import classNames from 'classnames'; -export const PayPalCheckbox = ( props ) => { - let isChecked = null; +export const PayPalCheckbox = ( { + currentValue, + label, + value, + checked = null, + disabled = null, + changeCallback, +} ) => { + let isChecked = checked; - if ( Array.isArray( props.currentValue ) ) { - isChecked = props.currentValue.includes( props.value ); - } else { - isChecked = props.currentValue; + if ( null === isChecked ) { + if ( Array.isArray( currentValue ) ) { + isChecked = currentValue.includes( value ); + } else { + isChecked = currentValue; + } } + const className = classNames( { 'is-disabled': disabled } ); + + const onChange = ( newState ) => { + let newValue; + + if ( ! Array.isArray( currentValue ) ) { + newValue = newState; + } else if ( newState ) { + newValue = [ ...currentValue, value ]; + } else { + newValue = currentValue.filter( + ( optionValue ) => optionValue !== value + ); + } + + changeCallback( newValue ); + }; + return ( -
- - handleCheckboxState( checked, props ) - } - /> -
+ ); }; -export const PayPalCheckboxGroup = ( props ) => { - const renderCheckboxGroup = () => { - return props.value.map( ( checkbox ) => { - return ( - - ); - } ); - }; +export const CheckboxGroup = ( { options, value, onChange } ) => ( + <> + { options.map( ( checkbox ) => ( + + ) ) } + +); - return <>{ renderCheckboxGroup() }; -}; - -export const PayPalRdb = ( props ) => { +export const PayPalRdb = ( { + id, + name, + value, + currentValue, + handleRdbState, +} ) => { return (
+ { /* todo: Can we remove the wrapper div? */ } props.handleRdbState( props.value ) } + id={ id } + checked={ value === currentValue } + name={ name } + value={ value } + onChange={ () => handleRdbState( value ) } />
); }; -export const PayPalRdbWithContent = ( props ) => { - const className = [ 'ppcp-r__radio-wrapper' ]; - - if ( props?.className ) { - className.push( props.className ); - } +export const PayPalRdbWithContent = ( { + className, + id, + name, + label, + description, + value, + currentValue, + handleRdbState, + toggleAdditionalContent, + children, +} ) => { + const wrapperClasses = classNames( 'ppcp-r__radio-wrapper', className ); return (
-
- +
+ +
- - { props.description && ( + + { description && (

) }

- { props?.toggleAdditionalContent && - props.children && - props.value === props.currentValue && ( -
- { props.children } -
- ) } + { toggleAdditionalContent && children && value === currentValue && ( +
+ { children } +
+ ) }
); }; - -export const handleCheckboxState = ( checked, props ) => { - let newValue = null; - if ( ! Array.isArray( props.currentValue ) ) { - newValue = checked; - } else if ( checked ) { - newValue = [ ...props.currentValue, props.value ]; - } else { - newValue = props.currentValue.filter( - ( value ) => value !== props.value - ); - } - props.changeCallback( newValue ); -}; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/HStack.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/HStack.js new file mode 100644 index 000000000..2c54ac7da --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/HStack.js @@ -0,0 +1,26 @@ +/** + * Temporary component, until the experimental HStack block editor component is stable. + * + * @see https://wordpress.github.io/gutenberg/?path=/docs/components-experimental-hstack--docs + * @file + */ +import classNames from 'classnames'; + +const HStack = ( { className, spacing = 3, children } ) => { + const wrapperClass = classNames( + 'components-flex components-h-stack', + className + ); + + const styles = { + gap: `calc(${ 4 * spacing }px)`, + }; + + return ( +
+ { children } +
+ ); +}; + +export default HStack; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js deleted file mode 100644 index 3344c3ceb..000000000 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js +++ /dev/null @@ -1 +0,0 @@ -export { default as openSignup } from './Icons/open-signup'; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/index.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/index.js new file mode 100644 index 000000000..005d1bc58 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/index.js @@ -0,0 +1,5 @@ +export { default as openSignup } from './open-signup'; +export { default as logoPayPal } from './logo-paypal'; + +export const NOTIFICATION_SUCCESS = '✔️'; +export const NOTIFICATION_ERROR = '❌'; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/logo-paypal.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/logo-paypal.js new file mode 100644 index 000000000..fec3f38a3 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/logo-paypal.js @@ -0,0 +1,12 @@ +import { SVG, Path } from '@wordpress/primitives'; + +const logoPayPal = ( + + + +); + +export default logoPayPal; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/open-signup.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/open-signup.js index 83c792f22..8e8daa32b 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/open-signup.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/open-signup.js @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { SVG, Path } from '@wordpress/primitives'; const openSignup = ( diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/PricingTitleBadge.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/PricingTitleBadge.js index 33825d96b..5603d10e4 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/PricingTitleBadge.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/PricingTitleBadge.js @@ -5,26 +5,29 @@ import { countryPriceInfo } from '../../utils/countryPriceInfo'; import { formatPrice } from '../../utils/formatPrice'; import TitleBadge, { TITLE_BADGE_INFO } from './TitleBadge'; -const getFixedAmount = ( currency, priceList ) => { - if ( priceList[ currency ] ) { - return formatPrice( priceList[ currency ], currency ); +const getFixedAmount = ( currency, priceList, itemFixedAmount ) => { + if ( priceList[ currency ] ) { + const sum = priceList[ currency ] + itemFixedAmount; + return formatPrice( sum, currency ); } const [ defaultCurrency, defaultPrice ] = Object.entries( priceList )[ 0 ]; - - return formatPrice( defaultPrice, defaultCurrency ); + const sum = defaultPrice + itemFixedAmount; + return formatPrice( sum, defaultCurrency ); }; const PricingTitleBadge = ( { item } ) => { - const { storeCountry } = CommonHooks.useWooSettings(); + const { storeCountry, storeCurrency } = CommonHooks.useWooSettings(); const infos = countryPriceInfo[ storeCountry ]; + const itemKey = item.split(' ')[0]; // Extract the first word, fastlane has more than one - if ( ! infos || ! infos[ item ] ) { + if ( ! infos || ! infos[ itemKey ] ) { return null; } - const percentage = infos[ item ].toFixed( 2 ); - const fixedAmount = getFixedAmount( storeCountry, infos.fixedFee ); + const percentage = typeof infos[itemKey] === 'number' ? infos[itemKey].toFixed(2) : infos[itemKey]['percentage'].toFixed(2); + const itemFixedAmount = infos[itemKey]['fixedFee'] ? infos[itemKey]['fixedFee'] : 0; + const fixedAmount = getFixedAmount( storeCurrency, infos.fixedFee, itemFixedAmount ); const label = sprintf( __( 'from %1$s%% + %2$s', 'woocommerce-paypal-payments' ), diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/ButtonSettingsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/ButtonSettingsBlock.js index e66223ba5..eabdde934 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/ButtonSettingsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/ButtonSettingsBlock.js @@ -1,6 +1,6 @@ import { Button } from '@wordpress/components'; import SettingsBlock from './SettingsBlock'; -import { Header, Title, Action, Description } from './SettingsBlockElements'; +import { Action, Description, Header, Title } from './SettingsBlockElements'; const ButtonSettingsBlock = ( { title, description, ...props } ) => ( @@ -10,6 +10,7 @@ const ButtonSettingsBlock = ( { title, description, ...props } ) => ( diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/FeatureSettingsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/FeatureSettingsBlock.js index e2681a909..3f747bda7 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/FeatureSettingsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/FeatureSettingsBlock.js @@ -19,6 +19,28 @@ const FeatureSettingsBlock = ( { title, description, ...props } ) => { ); }; + const renderButton = ( button ) => { + const buttonElement = ( + + ); + + return button.urls ? ( + + { buttonElement } + + ) : ( + buttonElement + ); + }; + return (
@@ -35,15 +57,7 @@ const FeatureSettingsBlock = ( { title, description, ...props } ) => {
- { props.actionProps?.buttons.map( ( button ) => ( - - ) ) } + { props.actionProps?.buttons.map( renderButton ) }
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodItemBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodItemBlock.js index cdad46b3e..cf26431a3 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodItemBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodItemBlock.js @@ -1,51 +1,50 @@ -import { useState } from '@wordpress/element'; import { ToggleControl } from '@wordpress/components'; import SettingsBlock from './SettingsBlock'; import PaymentMethodIcon from '../PaymentMethodIcon'; import data from '../../../utils/data'; +import { hasSettings } from '../../Screens/Overview/TabSettingsElements/Blocks/PaymentMethods'; -const PaymentMethodItemBlock = ( props ) => { - const [ toggleIsChecked, setToggleIsChecked ] = useState( false ); - const [ modalIsVisible, setModalIsVisible ] = useState( false ); - const Modal = props?.modal; +const PaymentMethodItemBlock = ( { + id, + title, + description, + icon, + onTriggerModal, + onSelect, + isSelected, +} ) => { + // Only show settings icon if this method has fields configured + const hasModal = hasSettings( id ); return ( - <> - -
-
- - - { props.title } - -
-

- { props.description } -

-
- - { Modal && ( -
setModalIsVisible( true ) } - > - { data().getImage( 'icon-settings.svg' ) } -
- ) } -
+ +
+
+ + + { title } +
- - { Modal && modalIsVisible && ( - - ) } - +

+ { description } +

+
+ + { hasModal && onTriggerModal && ( +
+ { data().getImage( 'icon-settings.svg' ) } +
+ ) } +
+
+
); }; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodsBlock.js index 3ffb91051..1e1e9ee30 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodsBlock.js @@ -1,18 +1,25 @@ -import { useState, useCallback } from '@wordpress/element'; import SettingsBlock from './SettingsBlock'; import PaymentMethodItemBlock from './PaymentMethodItemBlock'; +import { usePaymentMethods } from '../../../data/payment/hooks'; -const PaymentMethodsBlock = ( { paymentMethods, className = '' } ) => { - const [ selectedMethod, setSelectedMethod ] = useState( null ); +const PaymentMethodsBlock = ( { + paymentMethods, + className = '', + onTriggerModal, +} ) => { + const { setPersistent } = usePaymentMethods(); - const handleSelect = useCallback( ( methodId, isSelected ) => { - setSelectedMethod( isSelected ? methodId : null ); - }, [] ); - - if ( paymentMethods.length === 0 ) { + if ( ! paymentMethods?.length ) { return null; } + const handleSelect = ( paymentMethod, isSelected ) => { + setPersistent( paymentMethod.id, { + ...paymentMethod, + enabled: isSelected, + } ); + }; + return ( { - handleSelect( paymentMethod.id, checked ) + handleSelect( paymentMethod, checked ) + } + onTriggerModal={ () => + onTriggerModal?.( paymentMethod.id ) } /> ) ) } diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlock.js index 768fa9387..e033a69df 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlock.js @@ -1,9 +1,11 @@ -const SettingsBlock = ( { className, children } ) => { - const blockClassName = [ 'ppcp-r-settings-block', className ].filter( - Boolean - ); +import classNames from 'classnames'; - return
{ children }
; +const SettingsBlock = ( { className, children, separatorAndGap = true } ) => { + const blockClassName = classNames( 'ppcp-r-settings-block', className, { + 'no-gap': ! separatorAndGap, + } ); + + return
{ children }
; }; export default SettingsBlock; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlockElements.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlockElements.js index 296c2c2ad..46048e071 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlockElements.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlockElements.js @@ -1,9 +1,24 @@ +import classNames from 'classnames'; + // Block Elements -export const Title = ( { children, className = '' } ) => ( - - { children } - -); +export const Title = ( { + children, + altStyle = false, + big = false, + className = '', +} ) => { + const elementClasses = classNames( + 'ppcp-r-settings-block__title', + className, + { + 'style-alt': altStyle, + 'style-big': big, + } + ); + + return { children }; +}; + export const TitleWrapper = ( { children } ) => ( { children } ); @@ -14,13 +29,28 @@ export const SupplementaryLabel = ( { children } ) => ( ); -export const Description = ( { children, className = '' } ) => ( - - { children } - -); +export const Description = ( { children, asHtml = false, className = '' } ) => { + // Don't output anything if description is empty. + if ( ! children ) { + return null; + } + + const elementClasses = classNames( + 'ppcp-r-settings-block__description', + className + ); + + if ( ! asHtml ) { + return { children }; + } + + return ( + + ); +}; export const Action = ( { children } ) => (
{ children }
@@ -33,9 +63,18 @@ export const Header = ( { children, className = '' } ) => ( ); // Card Elements -export const Content = ( { children } ) => ( -
{ children }
-); +export const Content = ( { children, className = '', id = '' } ) => { + const elementClasses = classNames( + 'ppcp-r-settings-card__content', + className + ); + + return ( +
+ { children } +
+ ); +}; export const ContentWrapper = ( { children } ) => (
{ children }
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/TodoSettingsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/TodoSettingsBlock.js index 4f9b01644..9ddf59196 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/TodoSettingsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/TodoSettingsBlock.js @@ -1,13 +1,4 @@ -import { PayPalCheckbox, handleCheckboxState } from '../Fields'; -import data from '../../../utils/data'; - -const TodoSettingsBlock = ( { - todos, - setTodos, - todosData, - setTodosData, - className = '', -} ) => { +const TodoSettingsBlock = ( { todosData, className = '' } ) => { if ( todosData.length === 0 ) { return null; } @@ -16,54 +7,33 @@ const TodoSettingsBlock = ( {
- { todosData.map( ( todo ) => ( - - ) ) } + { todosData + .slice( 0, 5 ) + .filter( ( todo ) => { + return ! todo.isCompleted(); + } ) + .map( ( todo ) => ( + + ) ) }
); }; const TodoItem = ( props ) => { return ( -
+
- +
- { props.description } + { props.title }
-
- removeTodo( - props.value, - props.todosData, - props.changeTodos - ) - } - > - { data().getImage( 'icon-close.svg' ) } -
); }; -const removeTodo = ( todoValue, todosData, changeTodos ) => { - changeTodos( todosData.filter( ( todo ) => todo.value !== todoValue ) ); -}; - export default TodoSettingsBlock; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsCard.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsCard.js index aeb0e3561..f209daec1 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsCard.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsCard.js @@ -1,6 +1,9 @@ +import classNames from 'classnames'; + import { Content, ContentWrapper } from './SettingsBlocks'; const SettingsCard = ( { + id, className: extraClassName, title, description, @@ -8,17 +11,17 @@ const SettingsCard = ( { contentItems, contentContainer = true, } ) => { - const className = [ 'ppcp-r-settings-card', extraClassName ] - .filter( Boolean ) - .join( ' ' ); + const className = classNames( 'ppcp-r-settings-card', extraClassName ); const renderContent = () => { // If contentItems array is provided, wrap each item in Content component if ( contentItems ) { return ( - { contentItems.map( ( item, index ) => ( - { item } + { contentItems.map( ( item ) => ( + + { item } + ) ) } ); @@ -33,7 +36,7 @@ const SettingsCard = ( { }; return ( -
+
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/TabNavigation.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/TabNavigation.js index e22711255..0ae4e001e 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/TabNavigation.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/TabNavigation.js @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState } from '@wordpress/element'; + +// TODO: Migrate to Tabs (TabPanel v2) once its API is publicly available, as it provides programmatic tab switching support: https://github.com/WordPress/gutenberg/issues/52997 import { TabPanel } from '@wordpress/components'; import { getQuery, updateQueryString } from '../../utils/navigation'; @@ -41,9 +43,7 @@ const TabNavigation = ( { tabs } ) => { onSelect={ updateActivePanel } tabs={ tabs } > - { ( tab ) => { - return tab.component || <>{ tab.title ?? tab.name }; - } } + { ( { Component } ) => Component } ); }; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/TopNavigation.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/TopNavigation.js new file mode 100644 index 000000000..ee3ca7aaa --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/TopNavigation.js @@ -0,0 +1,86 @@ +import { useCallback, useLayoutEffect } from '@wordpress/element'; +import { Button, Icon } from '@wordpress/components'; +import { chevronLeft } from '@wordpress/icons'; +import classNames from 'classnames'; + +import useIsScrolled from '../../hooks/useIsScrolled'; +import { useNavigation } from '../../hooks/useNavigation'; +import BusyStateWrapper from './BusyStateWrapper'; + +const TopNavigation = ( { + title, + children, + isMainTitle = true, + exitOnTitleClick = false, + onTitleClick = null, + showProgressBar = false, + progressBarPercent = 0, +} ) => { + const { goToWooCommercePaymentsTab } = useNavigation(); + const { isScrolled } = useIsScrolled(); + + const className = classNames( 'ppcp-r-navigation-container', { + 'is-scrolled': isScrolled, + } ); + const titleClassName = classNames( 'title', { + big: isMainTitle, + } ); + + const handleTitleClick = useCallback( () => { + if ( exitOnTitleClick ) { + goToWooCommercePaymentsTab(); + } else if ( 'function' === typeof onTitleClick ) { + onTitleClick(); + } + }, [ exitOnTitleClick, goToWooCommercePaymentsTab, onTitleClick ] ); + + // Removes the excess padding at the top of the navigation bar. + useLayoutEffect( () => { + window.dispatchEvent( new Event( 'resize' ) ); + }, [] ); + + return ( +
+
+ + + + + + { children } + + + { showProgressBar && ( + + ) } +
+
+ ); +}; + +const ProgressBar = ( { percent } ) => { + percent = Math.min( Math.max( percent, 0 ), 100 ); + + return ( +
+ ); +}; + +export default TopNavigation; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AcdcFlow.js similarity index 94% rename from modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AcdcFlow.js index 89d4455e1..a7e9ad735 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AcdcFlow.js @@ -1,9 +1,11 @@ import { __, sprintf } from '@wordpress/i18n'; -import BadgeBox, { BADGE_BOX_TITLE_BIG } from '../BadgeBox'; -import Separator from '../Separator'; -import OptionalPaymentMethods from '../OptionalPaymentMethods/OptionalPaymentMethods'; -import PricingTitleBadge from '../PricingTitleBadge'; +import BadgeBox, { + BADGE_BOX_TITLE_BIG, +} from '../../../ReusableComponents/BadgeBox'; +import Separator from '../../../ReusableComponents/Separator'; +import PricingTitleBadge from '../../../ReusableComponents/PricingTitleBadge'; +import OptionalPaymentMethods from './OptionalPaymentMethods'; const AcdcFlow = ( { isFastlane, isPayLater, storeCountry } ) => { if ( isFastlane && isPayLater && storeCountry === 'US' ) { @@ -53,6 +55,7 @@ const AcdcFlow = ( { isFastlane, isPayLater, storeCountry } ) => { imageBadge={ [ 'icon-payment-method-paypal-small.svg', ] } + textBadge={ } description={ sprintf( // translators: %s: Link to PayPal business fees guide __( @@ -92,12 +95,12 @@ const AcdcFlow = ( { isFastlane, isPayLater, storeCountry } ) => {
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/AcdcOptionalPaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AcdcOptionalPaymentMethods.js similarity index 97% rename from modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/AcdcOptionalPaymentMethods.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AcdcOptionalPaymentMethods.js index 6066ac470..836c85689 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/AcdcOptionalPaymentMethods.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AcdcOptionalPaymentMethods.js @@ -1,8 +1,8 @@ import { __, sprintf } from '@wordpress/i18n'; -import BadgeBox from '../BadgeBox'; -import Separator from '../Separator'; -import PricingTitleBadge from '../PricingTitleBadge'; +import BadgeBox from '../../../ReusableComponents/BadgeBox'; +import Separator from '../../../ReusableComponents/Separator'; +import PricingTitleBadge from '../../../ReusableComponents/PricingTitleBadge'; const AcdcOptionalPaymentMethods = ( { isFastlane, diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AdvancedOptionsForm.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AdvancedOptionsForm.js index 6aabd15fd..4d1891735 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AdvancedOptionsForm.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AdvancedOptionsForm.js @@ -1,208 +1,13 @@ -import { __, sprintf } from '@wordpress/i18n'; -import { Button, TextControl } from '@wordpress/components'; -import { - useRef, - useState, - useEffect, - useMemo, - useCallback, -} from '@wordpress/element'; - -import classNames from 'classnames'; - -import SettingsToggleBlock from '../../../ReusableComponents/SettingsToggleBlock'; import Separator from '../../../ReusableComponents/Separator'; -import DataStoreControl from '../../../ReusableComponents/DataStoreControl'; -import { CommonHooks } from '../../../../data'; -import { - useSandboxConnection, - useManualConnection, -} from '../../../../hooks/useHandleConnections'; - -import ConnectionButton from './ConnectionButton'; -import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; - -const FORM_ERRORS = { - noClientId: __( - 'Please enter your Client ID', - 'woocommerce-paypal-payments' - ), - noClientSecret: __( - 'Please enter your Secret Key', - 'woocommerce-paypal-payments' - ), - invalidClientId: __( - 'Please enter a valid Client ID', - 'woocommerce-paypal-payments' - ), -}; +import SandboxConnectionForm from './SandboxConnectionForm'; +import ManualConnectionForm from './ManualConnectionForm'; const AdvancedOptionsForm = () => { - const [ clientValid, setClientValid ] = useState( false ); - const [ secretValid, setSecretValid ] = useState( false ); - - const { isBusy } = CommonHooks.useBusyState(); - const { isSandboxMode, setSandboxMode } = useSandboxConnection(); - const { - handleConnectViaIdAndSecret, - isManualConnectionMode, - setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - } = useManualConnection(); - - const refClientId = useRef( null ); - const refClientSecret = useRef( null ); - - const validateManualConnectionForm = useCallback( () => { - const checks = [ - { - ref: refClientId, - valid: () => clientId, - errorMessage: FORM_ERRORS.noClientId, - }, - { - ref: refClientId, - valid: () => clientValid, - errorMessage: FORM_ERRORS.invalidClientId, - }, - { - ref: refClientSecret, - valid: () => clientSecret && secretValid, - errorMessage: FORM_ERRORS.noClientSecret, - }, - ]; - - for ( const { ref, valid, errorMessage } of checks ) { - if ( valid() ) { - continue; - } - - ref?.current?.focus(); - throw new Error( errorMessage ); - } - }, [ clientId, clientSecret, clientValid, secretValid ] ); - - const handleManualConnect = useCallback( - () => - handleConnectViaIdAndSecret( { - validation: validateManualConnectionForm, - } ), - [ handleConnectViaIdAndSecret, validateManualConnectionForm ] - ); - - useEffect( () => { - setClientValid( ! clientId || /^A[\w-]{79}$/.test( clientId ) ); - setSecretValid( clientSecret && clientSecret.length > 0 ); - }, [ clientId, clientSecret ] ); - - const clientIdLabel = useMemo( - () => - isSandboxMode - ? __( 'Sandbox Client ID', 'woocommerce-paypal-payments' ) - : __( 'Live Client ID', 'woocommerce-paypal-payments' ), - [ isSandboxMode ] - ); - - const secretKeyLabel = useMemo( - () => - isSandboxMode - ? __( 'Sandbox Secret Key', 'woocommerce-paypal-payments' ) - : __( 'Live Secret Key', 'woocommerce-paypal-payments' ), - [ isSandboxMode ] - ); - - const advancedUsersDescription = sprintf( - // translators: %s: Link to PayPal REST application guide - __( - 'For advanced users: Connect a custom PayPal REST app for full control over your integration. For more information on creating a PayPal REST application, click here.', - 'woocommerce-paypal-payments' - ), - 'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input' - ); - return ( <> - - - - - + - ( { - disabled: true, - label: props.label + ' ...', - } ) } - > - - - { clientValid || ( -

- { FORM_ERRORS.invalidClientId } -

- ) } - - -
-
+ ); }; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/BcdcFlow.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/BcdcFlow.js similarity index 92% rename from modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/BcdcFlow.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/BcdcFlow.js index 591412be3..38f2830bb 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/BcdcFlow.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/BcdcFlow.js @@ -1,9 +1,11 @@ import { __, sprintf } from '@wordpress/i18n'; -import BadgeBox, { BADGE_BOX_TITLE_BIG } from '../BadgeBox'; -import Separator from '../Separator'; -import OptionalPaymentMethods from '../OptionalPaymentMethods/OptionalPaymentMethods'; -import PricingTitleBadge from '../PricingTitleBadge'; +import BadgeBox, { + BADGE_BOX_TITLE_BIG, +} from '../../../ReusableComponents/BadgeBox'; +import Separator from '../../../ReusableComponents/Separator'; +import PricingTitleBadge from '../../../ReusableComponents/PricingTitleBadge'; +import OptionalPaymentMethods from './OptionalPaymentMethods'; const BcdcFlow = ( { isPayLater, storeCountry } ) => { if ( isPayLater && storeCountry === 'US' ) { @@ -92,12 +94,12 @@ const BcdcFlow = ( { isPayLater, storeCountry } ) => {
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/BcdcOptionalPaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/BcdcOptionalPaymentMethods.js similarity index 92% rename from modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/BcdcOptionalPaymentMethods.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/BcdcOptionalPaymentMethods.js index 4307636e2..e2ed9978b 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/BcdcOptionalPaymentMethods.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/BcdcOptionalPaymentMethods.js @@ -1,7 +1,7 @@ import { __, sprintf } from '@wordpress/i18n'; -import BadgeBox from '../BadgeBox'; -import PricingTitleBadge from '../PricingTitleBadge'; +import BadgeBox from '../../../ReusableComponents/BadgeBox'; +import PricingTitleBadge from '../../../ReusableComponents/PricingTitleBadge'; const BcdcOptionalPaymentMethods = ( { isPayLater, storeCountry } ) => { if ( isPayLater && storeCountry === 'us' ) { diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js index ad6a7dcef..7cbc504e0 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js @@ -1,15 +1,45 @@ import { Button } from '@wordpress/components'; - +import { useEffect } from '@wordpress/element'; import classNames from 'classnames'; - -import { CommonHooks } from '../../../../data'; import { openSignup } from '../../../ReusableComponents/Icons'; -import { - useProductionConnection, - useSandboxConnection, -} from '../../../../hooks/useHandleConnections'; +import { useHandleOnboardingButton } from '../../../../hooks/useHandleConnections'; import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; +/** + * Button component that outputs a placeholder button when no onboardingUrl is present yet - the + * placeholder button looks identical to the working button, but has no href, target, or + * custom connection attributes. + * + * @param {Object} props + * @param {string} props.className + * @param {string} props.variant + * @param {boolean} props.showIcon + * @param {?string} props.href + * @param {Element} props.children + */ +const ButtonOrPlaceholder = ( { + className, + variant, + showIcon, + href, + children, +} ) => { + const buttonProps = { + className, + variant, + icon: showIcon ? openSignup : null, + }; + + if ( href ) { + buttonProps.href = href; + buttonProps.target = 'PPFrame'; + buttonProps[ 'data-paypal-button' ] = 'true'; + buttonProps[ 'data-paypal-onboard-button' ] = 'true'; + } + + return ; +}; + const ConnectionButton = ( { title, isSandbox = false, @@ -17,31 +47,45 @@ const ConnectionButton = ( { showIcon = true, className = '', } ) => { - const { handleSandboxConnect } = useSandboxConnection(); - const { handleProductionConnect } = useProductionConnection(); + const { + onboardingUrl, + scriptLoaded, + setCompleteHandler, + removeCompleteHandler, + } = useHandleOnboardingButton( isSandbox ); const buttonClassName = classNames( 'ppcp-r-connection-button', className, { 'sandbox-mode': isSandbox, 'live-mode': ! isSandbox, } ); + const environment = isSandbox ? 'sandbox' : 'production'; - const handleConnectClick = async () => { - if ( isSandbox ) { - await handleSandboxConnect(); - } else { - await handleProductionConnect(); + useEffect( () => { + if ( scriptLoaded && onboardingUrl ) { + window.PAYPAL.apps.Signup.render(); + setCompleteHandler( environment ); } - }; + + return () => { + removeCompleteHandler(); + }; + }, [ + scriptLoaded, + onboardingUrl, + environment, + setCompleteHandler, + removeCompleteHandler, + ] ); return ( - - + ); }; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js new file mode 100644 index 000000000..ca0257159 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js @@ -0,0 +1,188 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from '@wordpress/element'; +import { Button, TextControl } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import classNames from 'classnames'; + +import SettingsToggleBlock from '../../../ReusableComponents/SettingsToggleBlock'; +import DataStoreControl from '../../../ReusableComponents/DataStoreControl'; +import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; +import { + useDirectAuthentication, + useSandboxConnection, +} from '../../../../hooks/useHandleConnections'; +import { OnboardingHooks } from '../../../../data'; + +const FORM_ERRORS = { + noClientId: __( + 'Please enter your Client ID', + 'woocommerce-paypal-payments' + ), + noClientSecret: __( + 'Please enter your Secret Key', + 'woocommerce-paypal-payments' + ), + invalidClientId: __( + 'Please enter a valid Client ID', + 'woocommerce-paypal-payments' + ), +}; + +const ManualConnectionForm = () => { + const [ clientValid, setClientValid ] = useState( false ); + const [ secretValid, setSecretValid ] = useState( false ); + const { isSandboxMode } = useSandboxConnection(); + const { + manualClientId, + setManualClientId, + manualClientSecret, + setManualClientSecret, + } = OnboardingHooks.useManualConnectionForm(); + const { + handleDirectAuthentication, + isManualConnectionMode, + setManualConnectionMode, + } = useDirectAuthentication(); + const refClientId = useRef( null ); + const refClientSecret = useRef( null ); + + // Form data validation and sanitation. + const getManualConnectionDetails = useCallback( () => { + const checks = [ + { + ref: refClientId, + valid: () => manualClientId, + errorMessage: FORM_ERRORS.noClientId, + }, + { + ref: refClientId, + valid: () => clientValid, + errorMessage: FORM_ERRORS.invalidClientId, + }, + { + ref: refClientSecret, + valid: () => manualClientSecret && secretValid, + errorMessage: FORM_ERRORS.noClientSecret, + }, + ]; + + for ( const { ref, valid, errorMessage } of checks ) { + if ( valid() ) { + continue; + } + + ref?.current?.focus(); + throw new Error( errorMessage ); + } + + return { + clientId: manualClientId, + clientSecret: manualClientSecret, + isSandbox: isSandboxMode, + }; + }, [ + manualClientId, + manualClientSecret, + isSandboxMode, + clientValid, + secretValid, + ] ); + + // On-the-fly form validation. + useEffect( () => { + setClientValid( + ! manualClientId || /^A[\w-]{79}$/.test( manualClientId ) + ); + setSecretValid( manualClientSecret && manualClientSecret.length > 0 ); + }, [ manualClientId, manualClientSecret ] ); + + // Environment-specific field labels. + const clientIdLabel = useMemo( + () => + isSandboxMode + ? __( 'Sandbox Client ID', 'woocommerce-paypal-payments' ) + : __( 'Live Client ID', 'woocommerce-paypal-payments' ), + [ isSandboxMode ] + ); + + const secretKeyLabel = useMemo( + () => + isSandboxMode + ? __( 'Sandbox Secret Key', 'woocommerce-paypal-payments' ) + : __( 'Live Secret Key', 'woocommerce-paypal-payments' ), + [ isSandboxMode ] + ); + + // Translations with placeholders. + const advancedUsersDescription = sprintf( + // translators: %s: Link to PayPal REST application guide + __( + 'For advanced users: Connect a custom PayPal REST app for full control over your integration. For more information on creating a PayPal REST application, click here.', + 'woocommerce-paypal-payments' + ), + 'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input' + ); + + // Button click handler. + const handleManualConnect = useCallback( + () => handleDirectAuthentication( getManualConnectionDetails ), + [ handleDirectAuthentication, getManualConnectionDetails ] + ); + + return ( + ( { + disabled: true, + label: props.label + ' ...', + } ) } + > + + + { clientValid || ( +

+ { FORM_ERRORS.invalidClientId } +

+ ) } + + +
+
+ ); +}; + +export default ManualConnectionForm; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js index 3c12e1206..52d496b33 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js @@ -1,57 +1,27 @@ -import { Button, Icon } from '@wordpress/components'; -import { chevronLeft } from '@wordpress/icons'; +import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import classNames from 'classnames'; - import { OnboardingHooks } from '../../../../data'; -import useIsScrolled from '../../../../hooks/useIsScrolled'; -import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; +import { useNavigation } from '../../../../hooks/useNavigation'; +import TopNavigation from '../../../ReusableComponents/TopNavigation'; -const Navigation = ( { stepDetails, onNext, onPrev, onExit } ) => { +const OnboardingNavigation = ( { stepDetails, onNext, onPrev } ) => { + const { goToWooCommercePaymentsTab } = useNavigation(); const { title, isFirst, percentage, showNext, canProceed } = stepDetails; - const { isScrolled } = useIsScrolled(); const state = OnboardingHooks.useNavigationState(); const isDisabled = ! canProceed( state ); - const className = classNames( 'ppcp-r-navigation-container', { - 'is-scrolled': isScrolled, - } ); return ( -
-
- - - - { ! isFirst && - NextButton( { showNext, isDisabled, onNext, onExit } ) } - -
-
- ); -}; - -const NextButton = ( { showNext, isDisabled, onNext, onExit } ) => { - return ( - - { showNext && ( @@ -63,19 +33,8 @@ const NextButton = ( { showNext, isDisabled, onNext, onExit } ) => { { __( 'Continue', 'woocommerce-paypal-payments' ) } ) } - + ); }; -const ProgressBar = ( { percent } ) => { - percent = Math.min( Math.max( percent, 0 ), 100 ); - - return ( -
- ); -}; - -export default Navigation; +export default OnboardingNavigation; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OnboardingHeader.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/OnboardingHeader.js similarity index 61% rename from modules/ppcp-settings/resources/js/Components/ReusableComponents/OnboardingHeader.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/OnboardingHeader.js index 80fce1971..638af4704 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OnboardingHeader.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/OnboardingHeader.js @@ -1,11 +1,13 @@ -import data from '../../utils/data'; +import { Icon } from '@wordpress/components'; + +import { logoPayPal } from '../../../ReusableComponents/Icons'; const OnboardingHeader = ( props ) => { return (
- { data().getImage( 'logo-paypal.svg' ) } +
@@ -14,8 +16,10 @@ const OnboardingHeader = ( props ) => { { props.description && (

) }
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/OptionalPaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/OptionalPaymentMethods.js similarity index 100% rename from modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/OptionalPaymentMethods.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/OptionalPaymentMethods.js diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/SandboxConnectionForm.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/SandboxConnectionForm.js new file mode 100644 index 000000000..39b115b9d --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/SandboxConnectionForm.js @@ -0,0 +1,42 @@ +import { __ } from '@wordpress/i18n'; + +import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; +import SettingsToggleBlock from '../../../ReusableComponents/SettingsToggleBlock'; +import { useSandboxConnection } from '../../../../hooks/useHandleConnections'; +import ConnectionButton from './ConnectionButton'; + +const SandboxConnectionForm = () => { + const { isSandboxMode, setSandboxMode } = useSandboxConnection(); + + return ( + + + + + + ); +}; + +export default SandboxConnectionForm; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/WelcomeDocs.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/WelcomeDocs.js similarity index 89% rename from modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/WelcomeDocs.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/WelcomeDocs.js index cb8d2fe7b..c5c4d9efd 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/WelcomeDocs.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/WelcomeDocs.js @@ -1,6 +1,6 @@ import { __ } from '@wordpress/i18n'; -import PricingDescription from '../PricingDescription'; +import PricingDescription from '../../../ReusableComponents/PricingDescription'; import AcdcFlow from './AcdcFlow'; import BcdcFlow from './BcdcFlow'; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepBusiness.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepBusiness.js similarity index 87% rename from modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepBusiness.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepBusiness.js index dd9a1dcd5..d6e7af60e 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepBusiness.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepBusiness.js @@ -1,9 +1,9 @@ import { __ } from '@wordpress/i18n'; -import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; -import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper'; -import SelectBox from '../../ReusableComponents/SelectBox'; -import { OnboardingHooks, BUSINESS_TYPES } from '../../../data'; +import SelectBoxWrapper from '../../../ReusableComponents/SelectBoxWrapper'; +import SelectBox from '../../../ReusableComponents/SelectBox'; +import { OnboardingHooks, BUSINESS_TYPES } from '../../../../data'; +import OnboardingHeader from '../Components/OnboardingHeader'; const BUSINESS_RADIO_GROUP_NAME = 'business'; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepCompleteSetup.js similarity index 85% rename from modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepCompleteSetup.js index ed2001ac2..8c2fed3f0 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepCompleteSetup.js @@ -1,7 +1,7 @@ import { __ } from '@wordpress/i18n'; -import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; -import ConnectionButton from './Components/ConnectionButton'; +import OnboardingHeader from '../Components/OnboardingHeader'; +import ConnectionButton from '../Components/ConnectionButton'; const StepCompleteSetup = () => { return ( diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepPaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepPaymentMethods.js similarity index 68% rename from modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepPaymentMethods.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepPaymentMethods.js index ac56180a3..34581e2f4 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepPaymentMethods.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepPaymentMethods.js @@ -1,11 +1,11 @@ import { __ } from '@wordpress/i18n'; -import { CommonHooks, OnboardingHooks } from '../../../data'; -import OnboardingHeader from '../../ReusableComponents/OnboardingHeader'; -import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper'; -import SelectBox from '../../ReusableComponents/SelectBox'; -import OptionalPaymentMethods from '../../ReusableComponents/OptionalPaymentMethods/OptionalPaymentMethods'; -import PricingDescription from '../../ReusableComponents/PricingDescription'; +import { CommonHooks, OnboardingHooks } from '../../../../data'; +import SelectBoxWrapper from '../../../ReusableComponents/SelectBoxWrapper'; +import SelectBox from '../../../ReusableComponents/SelectBox'; +import PricingDescription from '../../../ReusableComponents/PricingDescription'; +import OnboardingHeader from '../Components/OnboardingHeader'; +import OptionalPaymentMethods from '../Components/OptionalPaymentMethods'; const OPM_RADIO_GROUP_NAME = 'optional-payment-methods'; @@ -17,14 +17,21 @@ const StepPaymentMethods = ( {} ) => { const { storeCountry, storeCurrency } = CommonHooks.useWooSettings(); + let screenTitle = __( + 'Add optional payment methods to your Checkout', + 'woocommerce-paypal-payments' + ); + + if ( 'US' === storeCountry ) { + screenTitle = __( + 'Add Expanded Checkout for More Ways to Pay', + 'woocommerce-paypal-payments' + ); + } + return (
- +
{ const { storeCountry } = CommonHooks.useWooSettings(); diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/availableSteps.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/index.js similarity index 100% rename from modules/ppcp-settings/resources/js/Components/Screens/Onboarding/availableSteps.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/index.js diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/index.js similarity index 70% rename from modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js rename to modules/ppcp-settings/resources/js/Components/Screens/Onboarding/index.js index 225527053..27bdaab9c 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/index.js @@ -1,27 +1,23 @@ import Container from '../../ReusableComponents/Container'; import { OnboardingHooks } from '../../../data'; -import { getSteps, getCurrentStep } from './availableSteps'; -import Navigation from './Components/Navigation'; +import { getSteps, getCurrentStep } from './Steps'; +import OnboardingNavigation from './Components/Navigation'; -const Onboarding = () => { +const OnboardingScreen = () => { const { step, setStep, flags } = OnboardingHooks.useSteps(); const Steps = getSteps( flags ); const currentStep = getCurrentStep( step, Steps ); const handleNext = () => setStep( currentStep.nextStep ); const handlePrev = () => setStep( currentStep.prevStep ); - const handleExit = () => { - window.location.href = window.ppcpSettings.wcPaymentsTabUrl; - }; return ( <> - @@ -37,4 +33,4 @@ const Onboarding = () => { ); }; -export default Onboarding; +export default OnboardingScreen; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalAcdc.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalAcdc.js deleted file mode 100644 index 3d924dcbe..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalAcdc.js +++ /dev/null @@ -1,62 +0,0 @@ -import PaymentMethodModal from '../../../ReusableComponents/PaymentMethodModal'; -import { __ } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; -import { useState } from '@wordpress/element'; -import { RadioControl } from '@wordpress/components'; - -const ModalAcdc = ( { setModalIsVisible } ) => { - const [ threeDSecure, setThreeDSecure ] = useState( 'no-3d-secure' ); - const acdcOptions = [ - { - label: __( 'No 3D Secure', 'woocommerce-paypal-payments' ), - value: 'no-3d-secure', - }, - { - label: __( 'Only when required', 'woocommerce-paypal-payments' ), - value: 'only-required-3d-secure', - }, - { - label: __( - 'Always require 3D Secure', - 'woocommerce-paypal-payments' - ), - value: 'always-3d-secure', - }, - ]; - - return ( - - - { __( '3D Secure', 'woocommerce-paypal-payments' ) } - -

- { __( - 'Authenticate cardholders through their card issuers to reduce fraud and improve transaction security. Successful 3D Secure authentication can shift liability for fraudulent chargebacks to the card issuer.', - 'woocommerce-paypal-payments' - ) } -

-
- - -
- -
-
-
- ); -}; - -export default ModalAcdc; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalFastlane.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalFastlane.js deleted file mode 100644 index 466f3b57f..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalFastlane.js +++ /dev/null @@ -1,63 +0,0 @@ -import PaymentMethodModal from '../../../ReusableComponents/PaymentMethodModal'; -import { __ } from '@wordpress/i18n'; -import { Button, ToggleControl } from '@wordpress/components'; -import { PayPalRdb } from '../../../ReusableComponents/Fields'; -import { useState } from '@wordpress/element'; - -const ModalFastlane = ( { setModalIsVisible } ) => { - const [ fastlaneSettings, setFastlaneSettings ] = useState( { - cardholderName: false, - displayWatermark: false, - } ); - - const updateFormValue = ( key, value ) => { - setFastlaneSettings( { ...fastlaneSettings, [ key ]: value } ); - }; - - return ( - -
-
- - updateFormValue( 'cardholderName', newValue ) - } - label={ __( - 'Display cardholder name', - 'woocommerce-paypal-payments' - ) } - id="ppcp-r-fastlane-settings-cardholder" - /> -
-
- - updateFormValue( 'displayWatermark', newValue ) - } - label={ __( - 'Display Fastlane Watermark', - 'woocommerce-paypal-payments' - ) } - id="ppcp-r-fastlane-settings-watermark" - /> -
-
- -
-
-
- ); -}; - -export default ModalFastlane; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalPayPal.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalPayPal.js deleted file mode 100644 index 780f7d9f1..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalPayPal.js +++ /dev/null @@ -1,76 +0,0 @@ -import PaymentMethodModal from '../../../ReusableComponents/PaymentMethodModal'; -import { __ } from '@wordpress/i18n'; -import { ToggleControl, Button, TextControl } from '@wordpress/components'; -import { useState } from '@wordpress/element'; - -const ModalPayPal = ( { setModalIsVisible } ) => { - const [ paypalSettings, setPaypalSettings ] = useState( { - checkoutPageTitle: 'PayPal', - checkoutPageDescription: 'Pay via PayPal', - showLogo: false, - } ); - - const updateFormValue = ( key, value ) => { - setPaypalSettings( { ...paypalSettings, [ key ]: value } ); - }; - - return ( - -
-
- - updateFormValue( 'checkoutPageTitle', newValue ) - } - /> -
-
- - updateFormValue( - 'checkoutPageDescription', - newValue - ) - } - /> -
-
-
-
- -
-
-
- ); -}; - -export default ModalPayPal; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js index 102426d0a..1b90bb609 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js @@ -1,8 +1,9 @@ import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; +import { useState, useMemo } from '@wordpress/element'; import { Button, Icon } from '@wordpress/components'; import { useDispatch } from '@wordpress/data'; import { reusableBlock } from '@wordpress/icons'; +import { store as noticesStore } from '@wordpress/notices'; import SettingsCard from '../../ReusableComponents/SettingsCard'; import TodoSettingsBlock from '../../ReusableComponents/SettingsBlocks/TodoSettingsBlock'; @@ -10,39 +11,76 @@ import FeatureSettingsBlock from '../../ReusableComponents/SettingsBlocks/Featur import { TITLE_BADGE_POSITIVE } from '../../ReusableComponents/TitleBadge'; import { useMerchantInfo } from '../../../data/common/hooks'; import { STORE_NAME } from '../../../data/common'; +import Features from './TabSettingsElements/Blocks/Features'; +import { todosData } from '../../../data/settings/tab-overview-todos-data'; +import { + NOTIFICATION_ERROR, + NOTIFICATION_SUCCESS, +} from '../../ReusableComponents/Icons'; const TabOverview = () => { - const [ todos, setTodos ] = useState( [] ); - const [ todosData, setTodosData ] = useState( todosDataDefault ); const [ isRefreshing, setIsRefreshing ] = useState( false ); - const { merchant } = useMerchantInfo(); - const { refreshFeatureStatuses } = useDispatch( STORE_NAME ); + const { merchant, merchantFeatures } = useMerchantInfo(); + const { refreshFeatureStatuses, setActiveModal } = + useDispatch( STORE_NAME ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); - const features = featuresDefault.map( ( feature ) => { - const merchantFeature = merchant?.features?.[ feature.id ]; - return { - ...feature, - enabled: merchantFeature?.enabled ?? false, - }; - } ); + // Get the features data with access to setActiveModal + const featuresData = useMemo( + () => Features.getFeatures( setActiveModal ), + [ setActiveModal ] + ); + + // Map merchant features status to our config + const features = useMemo( () => { + return featuresData.map( ( feature ) => { + const merchantFeature = merchantFeatures?.[ feature.id ]; + return { + ...feature, + enabled: merchantFeature?.enabled ?? false, + }; + } ); + }, [ featuresData, merchantFeatures ] ); const refreshHandler = async () => { setIsRefreshing( true ); + try { + const result = await refreshFeatureStatuses(); + if ( result && ! result.success ) { + const errorMessage = sprintf( + /* translators: %s: error message */ + __( + 'Operation failed: %s Check WooCommerce logs for more details.', + 'woocommerce-paypal-payments' + ), + result.message || + __( 'Unknown error', 'woocommerce-paypal-payments' ) + ); - const result = await refreshFeatureStatuses(); - - // TODO: Implement the refresh logic, remove this debug code -- PCP-4024 - if ( result && ! result.success ) { - console.error( - 'Failed to refresh features:', - result.message || 'Unknown error' - ); - } else { - console.log( 'Features refreshed successfully.' ); + createErrorNotice( errorMessage, { + icon: NOTIFICATION_ERROR, + } ); + console.error( + 'Failed to refresh features:', + result.message || 'Unknown error' + ); + } else { + createSuccessNotice( + __( + 'Features refreshed successfully.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_SUCCESS, + } + ); + console.log( 'Features refreshed successfully.' ); + } + } finally { + setIsRefreshing( false ); } - - setIsRefreshing( false ); }; return ( @@ -59,12 +97,7 @@ const TabOverview = () => { 'woocommerce-paypal-payments' ) } > - + ) } @@ -72,16 +105,16 @@ const TabOverview = () => { className="ppcp-r-tab-overview-features" title={ __( 'Features', 'woocommerce-paypal-payments' ) } description={ -
+ <>

{ __( - 'Enable additional features…', + 'Enable additional features and capabilities on your WooCommerce store.', 'woocommerce-paypal-payments' ) }

{ __( - 'Click Refresh…', + 'Click Refresh to update your current features after making changes.', 'woocommerce-paypal-payments' ) }

@@ -101,204 +134,106 @@ const TabOverview = () => { 'woocommerce-paypal-payments' ) } -
+ } - contentItems={ features.map( ( feature ) => ( + contentItems={ features.map( ( feature ) => { + return ( + + ! button.showWhen || // Learn more buttons + ( feature.enabled && + button.showWhen === + 'enabled' ) || + ( ! feature.enabled && + button.showWhen === 'disabled' ) + ) + .map( ( button ) => ( { + ...button, + url: button.urls + ? merchant?.isSandbox + ? button.urls.sandbox + : button.urls.live + : button.url, + } ) ), + isBusy: isRefreshing, + enabled: feature.enabled, + notes: feature.notes, + badge: feature.enabled + ? { + text: __( + 'Active', + 'woocommerce-paypal-payments' + ), + type: TITLE_BADGE_POSITIVE, + } + : undefined, + } } + /> + ); + } ) } + /> + + - ) ) } + />, + , + ] } />
); }; -// TODO: This list should be refactored into a separate module, maybe utils/thingsToDoNext.js -const todosDataDefault = [ - { - value: 'paypal_later_messaging', - description: __( - 'Enable Pay Later messaging', - 'woocommerce-paypal-payments' - ), - }, - { - value: 'capture_authorized_payments', - description: __( - 'Capture authorized payments', - 'woocommerce-paypal-payments' - ), - }, - { - value: 'enable_google_pay', - description: __( 'Enable Google Pay', 'woocommerce-paypal-payments' ), - }, - { - value: 'paypal_shortcut', - description: __( - 'Add PayPal shortcut to the Cart page', - 'woocommerce-paypal-payments' - ), - }, - { - value: 'advanced_cards', - description: __( - 'Add Advanced Cards to Blocks Checkout', - 'woocommerce-paypal-payments' - ), - }, -]; - -// TODO: Hardcoding this list here is not the best idea. Can we move this to a REST API response? -const featuresDefault = [ - { - id: 'save_paypal_and_venmo', - title: __( 'Save PayPal and Venmo', 'woocommerce-paypal-payments' ), - description: __( - 'Securely save PayPal and Venmo payment methods for subscriptions or return buyers.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Configure', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, - { - id: 'advanced_credit_and_debit_cards', - title: __( - 'Advanced Credit and Debit Cards', - 'woocommerce-paypal-payments' - ), - description: __( - 'Process major credit and debit cards including Visa, Mastercard, American Express and Discover.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Configure', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, - { - id: 'alternative_payment_methods', - title: __( - 'Alternative Payment Methods', - 'woocommerce-paypal-payments' - ), - description: __( - 'Offer global, country-specific payment options for your customers.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Apply', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, - { - id: 'google_pay', - title: __( 'Google Pay', 'woocommerce-paypal-payments' ), - description: __( - 'Let customers pay using their Google Pay wallet.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Configure', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - notes: [ - __( '¹PayPal Q2 Earnings-2021.', 'woocommerce-paypal-payments' ), - ], - }, - { - id: 'apple_pay', - title: __( 'Apple Pay', 'woocommerce-paypal-payments' ), - description: __( - 'Let customers pay using their Apple Pay wallet.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( - 'Domain registration', - 'woocommerce-paypal-payments' - ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, - { - id: 'pay_later_messaging', - title: __( 'Pay Later Messaging', 'woocommerce-paypal-payments' ), - description: __( - 'Let customers know they can buy now and pay later with PayPal. Adding this messaging can boost conversion rates and increase cart sizes by 39%¹, with no extra cost to you—plus, you get paid up front.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Configure', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, -]; - export default TabOverview; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPayLaterMessaging.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPayLaterMessaging.js new file mode 100644 index 000000000..a3a3083c9 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPayLaterMessaging.js @@ -0,0 +1,50 @@ +import React, { useEffect } from 'react'; + +const TabPayLaterMessaging = () => { + const config = {}; // Replace with the appropriate/saved configuration. + const PcpPayLaterConfigurator = + window.ppcpSettings?.PcpPayLaterConfigurator; + + useEffect( () => { + if ( window.merchantConfigurators && PcpPayLaterConfigurator ) { + window.merchantConfigurators.Messaging( { + config, + merchantClientId: PcpPayLaterConfigurator.merchantClientId, + partnerClientId: PcpPayLaterConfigurator.partnerClientId, + partnerName: 'WooCommerce', + bnCode: PcpPayLaterConfigurator.bnCode, + placements: [ + 'cart', + 'checkout', + 'product', + 'shop', + 'home', + 'custom_placement', + ], + styleOverrides: { + button: 'ppcp-r-paylater-configurator__publish-button', + header: 'ppcp-r-paylater-configurator__header', + subheader: 'ppcp-r-paylater-configurator__subheader', + }, + onSave: ( data ) => { + /* + TODO: + - The saving will be handled in a separate PR. + - One option could be: + - When saving the settings, programmatically click on the configurator's + "Save Changes" button and send the request to PHP. + */ + }, + } ); + } + }, [ PcpPayLaterConfigurator ] ); + + return ( +
+ ); +}; + +export default TabPayLaterMessaging; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPaymentMethods.js index c1576da10..3216faf40 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPaymentMethods.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPaymentMethods.js @@ -1,38 +1,39 @@ import { __ } from '@wordpress/i18n'; -import { useMemo } from '@wordpress/element'; import SettingsCard from '../../ReusableComponents/SettingsCard'; import PaymentMethodsBlock from '../../ReusableComponents/SettingsBlocks/PaymentMethodsBlock'; -import { CommonHooks } from '../../../data'; -import ModalPayPal from './Modals/ModalPayPal'; -import ModalFastlane from './Modals/ModalFastlane'; -import ModalAcdc from './Modals/ModalAcdc'; +import { PaymentHooks } from '../../../data'; +import { useActiveModal } from '../../../data/common/hooks'; +import Modal from './TabSettingsElements/Blocks/Modal'; const TabPaymentMethods = () => { - const { storeCountry, storeCurrency } = CommonHooks.useWooSettings(); + const { paymentMethodsPayPalCheckout } = + PaymentHooks.usePaymentMethodsPayPalCheckout(); + const { paymentMethodsOnlineCardPayments } = + PaymentHooks.usePaymentMethodsOnlineCardPayments(); + const { paymentMethodsAlternative } = + PaymentHooks.usePaymentMethodsAlternative(); - const filteredPaymentMethods = useMemo( () => { - const contextProps = { storeCountry, storeCurrency }; + const { activeModal, setActiveModal } = useActiveModal(); - return { - payPalCheckout: filterPaymentMethods( - paymentMethodsPayPalCheckout, - contextProps - ), - onlineCardPayments: filterPaymentMethods( - paymentMethodsOnlineCardPayments, - contextProps - ), - alternative: filterPaymentMethods( - paymentMethodsAlternative, - contextProps - ), - }; - }, [ storeCountry, storeCurrency ] ); + const getActiveMethod = () => { + if ( ! activeModal ) { + return null; + } + + const allMethods = [ + ...paymentMethodsPayPalCheckout, + ...paymentMethodsOnlineCardPayments, + ...paymentMethodsAlternative, + ]; + + return allMethods.find( ( method ) => method.id === activeModal ); + }; return (
{ contentContainer={ false } > { contentContainer={ false } > { contentContainer={ false } > + + { activeModal && ( + setActiveModal( null ) } + onSave={ ( methodId, settings ) => { + console.log( + 'Saving settings for:', + methodId, + settings + ); + setActiveModal( null ); + } } + /> + ) }
); }; -function filterPaymentMethods( paymentMethods, contextProps ) { - return paymentMethods.filter( ( method ) => - typeof method.condition === 'function' - ? method.condition( contextProps ) - : true - ); -} - -const paymentMethodsPayPalCheckout = [ - { - id: 'paypal', - title: __( 'PayPal', 'woocommerce-paypal-payments' ), - description: __( - 'Our all-in-one checkout solution lets you offer PayPal, Venmo, Pay Later options, and more to help maximize conversion.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-paypal', - modal: ModalPayPal, - }, - { - id: 'venmo', - title: __( 'Venmo', 'woocommerce-paypal-payments' ), - description: __( - 'Offer Venmo at checkout to millions of active users.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-venmo', - }, - { - id: 'paypal_credit', - title: __( 'PayPal Credit', 'woocommerce-paypal-payments' ), - description: __( - 'Get paid in full at checkout while giving your customers the option to pay interest free if paid within 6 months on orders over $99.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-paypal', - }, - { - id: 'credit_and_debit_card_payments', - title: __( - 'Credit and debit card payments', - 'woocommerce-paypal-payments' - ), - description: __( - "Accept all major credit and debit cards - even if your customer doesn't have a PayPal account.", - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-cards', - }, -]; - -const paymentMethodsOnlineCardPayments = [ - { - id: 'advanced_credit_and_debit_card_payments', - title: __( - 'Advanced Credit and Debit Card Payments', - 'woocommerce-paypal-payments' - ), - description: __( - "Present custom credit and debit card fields to your payers so they can pay with credit and debit cards using your site's branding.", - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-advanced-cards', - modal: ModalAcdc, - }, - { - id: 'fastlane', - title: __( 'Fastlane by PayPal', 'woocommerce-paypal-payments' ), - description: __( - "Tap into the scale and trust of PayPal's customer network to recognize shoppers and make guest checkout more seamless than ever.", - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-fastlane', - modal: ModalFastlane, - }, - { - id: 'apply_pay', - title: __( 'Apple Pay', 'woocommerce-paypal-payments' ), - description: __( - 'Allow customers to pay via their Apple Pay digital wallet.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-apple-pay', - }, - { - id: 'google_pay', - title: __( 'Google Pay', 'woocommerce-paypal-payments' ), - description: __( - 'Allow customers to pay via their Google Pay digital wallet.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-google-pay', - }, -]; - -const paymentMethodsAlternative = [ - { - id: 'bancontact', - title: __( 'Bancontact', 'woocommerce-paypal-payments' ), - description: __( - 'Bancontact is the most widely used, accepted and trusted electronic payment method in Belgium. Bancontact makes it possible to pay directly through the online payment systems of all major Belgian banks.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-bancontact', - }, - { - id: 'ideal', - title: __( 'iDEAL', 'woocommerce-paypal-payments' ), - description: __( - 'iDEAL is a payment method in the Netherlands that allows buyers to select their issuing bank from a list of options.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-ideal', - }, - { - id: 'eps', - title: __( 'eps', 'woocommerce-paypal-payments' ), - description: __( - 'An online payment method in Austria, enabling Austrian buyers to make secure payments directly through their bank accounts. Transactions are processed in EUR.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-eps', - }, - { - id: 'blik', - title: __( 'BLIK', 'woocommerce-paypal-payments' ), - description: __( - 'A widely used mobile payment method in Poland, allowing Polish customers to pay directly via their banking apps. Transactions are processed in PLN.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-blik', - }, - { - id: 'mybank', - title: __( 'MyBank', 'woocommerce-paypal-payments' ), - description: __( - 'A European online banking payment solution primarily used in Italy, enabling customers to make secure bank transfers during checkout. Transactions are processed in EUR.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-mybank', - }, - { - id: 'przelewy24', - title: __( 'Przelewy24', 'woocommerce-paypal-payments' ), - description: __( - 'A popular online payment gateway in Poland, offering various payment options for Polish customers. Transactions can be processed in PLN or EUR.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-przelewy24', - }, - { - id: 'trustly', - title: __( 'Trustly', 'woocommerce-paypal-payments' ), - description: __( - 'A European payment method that allows buyers to make payments directly from their bank accounts, suitable for customers across multiple European countries. Supported currencies include EUR, DKK, SEK, GBP, and NOK.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-trustly', - }, - { - id: 'multibanco', - title: __( 'Multibanco', 'woocommerce-paypal-payments' ), - description: __( - 'An online payment method in Portugal, enabling Portuguese buyers to make secure payments directly through their bank accounts. Transactions are processed in EUR.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-multibanco', - }, - { - id: 'pui', - title: __( 'Pay upon Invoice', 'woocommerce-paypal-payments' ), - description: __( - 'Pay upon Invoice is an invoice payment method in Germany. It is a local buy now, pay later payment method that allows the buyer to place an order, receive the goods, try them, verify they are in good order, and then pay the invoice within 30 days.', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-ratepay', - condition: ( { storeCountry, storeCurrency } ) => - storeCountry === 'DE' && storeCurrency === 'EUR', - }, - { - id: 'oxxo', - title: __( 'OXXO', 'woocommerce-paypal-payments' ), - description: __( - 'OXXO is a Mexican chain of convenience stores. *Get PayPal account permission to use OXXO payment functionality by contacting us at (+52) 800–925–0304', - 'woocommerce-paypal-payments' - ), - icon: 'payment-method-oxxo', - condition: ( { storeCountry, storeCurrency } ) => - storeCountry === 'MX' && storeCurrency === 'MXN', - }, -]; - export default TabPaymentMethods; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettings.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettings.js index 1b471fe1e..80d025af8 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettings.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettings.js @@ -1,31 +1,16 @@ -import { useState } from '@wordpress/element'; import ConnectionStatus from './TabSettingsElements/ConnectionStatus'; import CommonSettings from './TabSettingsElements/CommonSettings'; import ExpertSettings from './TabSettingsElements/ExpertSettings'; +import { useSettings } from '../../../data/settings-tab/hooks'; const TabSettings = () => { - const [ settings, setSettings ] = useState( { - invoicePrefix: '', - authorizeOnly: false, - captureVirtualOnlyOrders: false, - savePaypalAndVenmo: false, - saveCreditCardAndDebitCard: false, - payNowExperience: false, - sandboxAccountCredentials: false, - sandboxMode: null, - sandboxEnabled: false, - sandboxClientId: '', - sandboxSecretKey: '', - sandboxConnected: false, - logging: false, - subtotalMismatchFallback: null, - brandName: '', - softDescriptor: '', - paypalLandingPage: null, - buttonLanguage: '', - } ); + const { settings, setSettings } = useSettings(); + const updateFormValue = ( key, value ) => { - setSettings( { ...settings, [ key ]: value } ); + setSettings( { + ...settings, + [ key ]: value, + } ); }; return ( diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/ConnectionDetails.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/ConnectionDetails.js new file mode 100644 index 000000000..b1b5fcb4d --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/ConnectionDetails.js @@ -0,0 +1,41 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { + AccordionSettingsBlock, + RadioSettingsBlock, + InputSettingsBlock, +} from '../../../../ReusableComponents/SettingsBlocks'; +import { + sandboxData, + productionData, +} from '../../../../../data/settings/connection-details-data'; + +const ConnectionDetails = ( { settings, updateFormValue } ) => { + const isSandbox = settings.sandboxConnected; + + const modeConfig = isSandbox + ? productionData( { settings, updateFormValue } ) + : sandboxData( { settings, updateFormValue } ); + + const modeKey = isSandbox ? 'productionMode' : 'sandboxMode'; + + return ( + + + + ); +}; + +export default ConnectionDetails; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Features.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Features.js new file mode 100644 index 000000000..0529cba66 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Features.js @@ -0,0 +1,297 @@ +import { __ } from '@wordpress/i18n'; +import { TAB_IDS, selectTab } from '../../../../../utils/tabSelector'; + +const Features = { + getFeatures: ( setActiveModal ) => [ + { + id: 'save_paypal_and_venmo', + title: __( 'Save PayPal and Venmo', 'woocommerce-paypal-payments' ), + description: __( + 'Securely save PayPal and Venmo payment methods for subscriptions or return buyers.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-paypal-checkout-card' + ).then( () => { + setActiveModal( 'paypal' ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/bizsignup/entry?product=ADVANCED_VAULTING', + live: 'https://www.paypal.com/bizsignup/entry?product=ADVANCED_VAULTING', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + { + id: 'advanced_credit_and_debit_cards', + title: __( + 'Advanced Credit and Debit Cards', + 'woocommerce-paypal-payments' + ), + description: __( + 'Process major credit and debit cards including Visa, Mastercard, American Express and Discover.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-card-payments-card' + ).then( () => { + setActiveModal( + 'advanced_credit_and_debit_card_payments' + ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/bizsignup/entry?product=ppcp', + live: 'https://www.paypal.com/bizsignup/entry?product=ppcp', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + { + id: 'alternative_payment_methods', + title: __( + 'Alternative Payment Methods', + 'woocommerce-paypal-payments' + ), + description: __( + 'Offer global, country-specific payment options for your customers.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-alternative-payments-card' + ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + { + id: 'google_pay', + title: __( 'Google Pay', 'woocommerce-paypal-payments' ), + description: __( + 'Let customers pay using their Google Pay wallet.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-card-payments-card' + ).then( () => { + setActiveModal( 'google_pay' ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/bizsignup/add-product?product=payment_methods&capabilities=GOOGLE_PAY', + live: 'https://www.paypal.com/bizsignup/add-product?product=payment_methods&capabilities=GOOGLE_PAY', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + notes: [ + __( + '¹PayPal Q2 Earnings-2021.', + 'woocommerce-paypal-payments' + ), + ], + }, + { + id: 'apple_pay', + title: __( 'Apple Pay', 'woocommerce-paypal-payments' ), + description: __( + 'Let customers pay using their Apple Pay wallet.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-card-payments-card' + ).then( () => { + setActiveModal( 'apple_pay' ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( + 'Domain registration', + 'woocommerce-paypal-payments' + ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/uccservicing/apm/applepay', + live: 'https://www.paypal.com/uccservicing/apm/applepay', + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/bizsignup/add-product?product=payment_methods&capabilities=APPLE_PAY', + live: 'https://www.paypal.com/bizsignup/add-product?product=payment_methods&capabilities=APPLE_PAY', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + { + id: 'pay_later_messaging', + title: __( 'Pay Later Messaging', 'woocommerce-paypal-payments' ), + description: __( + 'Let customers know they can buy now and pay later with PayPal. Adding this messaging can boost conversion rates and increase cart sizes by 39%¹, with no extra cost to you—plus, you get paid up front.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-paypal-checkout-card' + ).then( () => { + setActiveModal( 'paypal' ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + ], +}; + +export default Features; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Modal.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Modal.js new file mode 100644 index 000000000..c8be1c761 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Modal.js @@ -0,0 +1,131 @@ +import { __ } from '@wordpress/i18n'; +import { + Button, + TextControl, + ToggleControl, + RadioControl, +} from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import PaymentMethodModal from '../../../../ReusableComponents/PaymentMethodModal'; +import { getPaymentMethods } from './PaymentMethods'; + +const Modal = ( { method, setModalIsVisible, onSave } ) => { + const [ settings, setSettings ] = useState( () => { + if ( ! method?.id ) { + return {}; + } + + const methodConfig = getPaymentMethods( method ); + if ( ! methodConfig?.fields ) { + return {}; + } + + const initialSettings = {}; + Object.entries( methodConfig.fields ).forEach( ( [ key, field ] ) => { + initialSettings[ key ] = field.default; + } ); + return initialSettings; + } ); + + if ( ! method?.id ) { + return null; + } + + const methodConfig = getPaymentMethods( method ); + if ( ! methodConfig?.fields ) { + return null; + } + + const renderField = ( key, field ) => { + switch ( field.type ) { + case 'text': + return ( +
+ + setSettings( ( prev ) => ( { + ...prev, + [ key ]: value, + } ) ) + } + /> +
+ ); + + case 'toggle': + return ( +
+ + setSettings( ( prev ) => ( { + ...prev, + [ key ]: value, + } ) ) + } + /> +
+ ); + + case 'radio': + return ( + <> + + { field.label } + + { field.description && ( +

+ { field.description } +

+ ) } +
+ + setSettings( ( prev ) => ( { + ...prev, + [ key ]: value, + } ) ) + } + /> +
+ + ); + + default: + return null; + } + }; + + const handleSave = () => { + onSave?.( method.id, settings ); + setModalIsVisible( false ); + }; + + return ( + +
+ { Object.entries( methodConfig.fields ).map( + ( [ key, field ] ) => renderField( key, field ) + ) } + +
+ +
+
+
+ ); +}; + +export default Modal; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/PaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/PaymentMethods.js new file mode 100644 index 000000000..658ec17d1 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/PaymentMethods.js @@ -0,0 +1,173 @@ +import { __, sprintf } from '@wordpress/i18n'; + +const createStandardFields = ( methodId, defaultTitle ) => ( { + checkoutPageTitle: { + type: 'text', + default: defaultTitle, + label: __( 'Checkout page title', 'woocommerce-paypal-payments' ), + }, + checkoutPageDescription: { + type: 'text', + default: sprintf( + /* translators: %s: payment method title */ + __( 'Pay with %s', 'woocommerce-paypal-payments' ), + defaultTitle + ), + label: __( 'Checkout page description', 'woocommerce-paypal-payments' ), + }, +} ); + +const paymentMethods = { + // PayPal Checkout methods + 'ppcp-gateway': { + fields: { + ...createStandardFields( 'paypal', 'PayPal' ), + showLogo: { + type: 'toggle', + default: false, + label: __( 'Show logo', 'woocommerce-paypal-payments' ), + }, + }, + }, + credit_and_debit_card_payments: { + fields: createStandardFields( + 'credit_and_debit_card_payments', + __( + 'Credit and debit card payments', + 'woocommerce-paypal-payments' + ) + ), + }, + + // Online Card Payments + advanced_credit_and_debit_card_payments: { + fields: { + ...createStandardFields( + 'advanced_credit_and_debit_card_payments', + __( + 'Advanced Credit and Debit Card Payments', + 'woocommerce-paypal-payments' + ) + ), + threeDSecure: { + type: 'radio', + default: 'no-3d-secure', + label: __( '3D Secure', 'woocommerce-paypal-payments' ), + description: __( + 'Authenticate cardholders through their card issuers to reduce fraud and improve transaction security. Successful 3D Secure authentication can shift liability for fraudulent chargebacks to the card issuer.', + 'woocommerce-paypal-payments' + ), + options: [ + { + label: __( + 'No 3D Secure', + 'woocommerce-paypal-payments' + ), + value: 'no-3d-secure', + }, + { + label: __( + 'Only when required', + 'woocommerce-paypal-payments' + ), + value: 'only-required-3d-secure', + }, + { + label: __( + 'Always require 3D Secure', + 'woocommerce-paypal-payments' + ), + value: 'always-3d-secure', + }, + ], + }, + }, + }, + fastlane: { + fields: { + ...createStandardFields( 'fastlane', 'Fastlane by PayPal' ), + cardholderName: { + type: 'toggle', + default: false, + label: __( + 'Display cardholder name', + 'woocommerce-paypal-payments' + ), + }, + displayWatermark: { + type: 'toggle', + default: false, + label: __( + 'Display Fastlane Watermark', + 'woocommerce-paypal-payments' + ), + }, + }, + }, + + // Digital Wallets + apple_pay: { + fields: createStandardFields( 'apple_pay', 'Apple Pay' ), + }, + google_pay: { + fields: createStandardFields( 'google_pay', 'Google Pay' ), + }, + + // Alternative Payment Methods + bancontact: { + fields: createStandardFields( 'bancontact', 'Bancontact' ), + }, + ideal: { + fields: createStandardFields( 'ideal', 'iDEAL' ), + }, + eps: { + fields: createStandardFields( 'eps', 'eps' ), + }, + blik: { + fields: createStandardFields( 'blik', 'BLIK' ), + }, + mybank: { + fields: createStandardFields( 'mybank', 'MyBank' ), + }, + przelewy24: { + fields: createStandardFields( 'przelewy24', 'Przelewy24' ), + }, + trustly: { + fields: createStandardFields( 'trustly', 'Trustly' ), + }, + multibanco: { + fields: createStandardFields( 'multibanco', 'Multibanco' ), + }, + pui: { + fields: createStandardFields( 'pui', 'Pay upon Invoice' ), + }, + oxxo: { + fields: createStandardFields( 'oxxo', 'OXXO' ), + }, +}; + +// Function to get configuration for a payment method +export const getPaymentMethods = ( method ) => { + if ( ! method?.id ) { + return null; + } + + // If method has specific config, return it + if ( paymentMethods[ method.id ] ) { + return { + ...paymentMethods[ method.id ], + icon: method.icon, + }; + } + + // Return standard config for new payment methods + return { + fields: createStandardFields( method.id, method.title ), + icon: method.icon, + }; +}; + +// Function to check if a method has settings defined +export const hasSettings = ( methodId ) => { + return Boolean( methodId && paymentMethods[ methodId ] ); +}; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Sandbox.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Sandbox.js deleted file mode 100644 index 93a4a7d0d..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Sandbox.js +++ /dev/null @@ -1,202 +0,0 @@ -import { __, sprintf } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; -import { - AccordionSettingsBlock, - ButtonSettingsBlock, - RadioSettingsBlock, - ToggleSettingsBlock, - InputSettingsBlock, -} from '../../../../ReusableComponents/SettingsBlocks'; -import TitleBadge, { - TITLE_BADGE_POSITIVE, -} from '../../../../ReusableComponents/TitleBadge'; -import ConnectionInfo, { - connectionStatusDataDefault, -} from '../../../../ReusableComponents/ConnectionInfo'; - -const Sandbox = ( { settings, updateFormValue } ) => { - const className = settings.sandboxConnected - ? 'ppcp-r-settings-block--sandbox-connected' - : 'ppcp-r-settings-block--sandbox-disconnected'; - - return ( - - { settings.sandboxConnected && ( - - } - > -
- - - -
-
- ) } - { ! settings.sandboxConnected && ( - - updateFormValue( - 'sandboxConnected', - true - ) - } - > - { __( - 'Connect Sandbox Account', - 'woocommerce-paypal-payments' - ) } - - ), - }, - { - id: 'manual_connect', - value: 'manual_connect', - label: __( - 'Manual Connect', - 'woocommerce-paypal-payments' - ), - description: sprintf( - __( - 'For advanced users: Connect a custom PayPal REST app for full control over your integration. For more information on creating a PayPal REST application, click here.', - 'woocommerce-paypal-payments' - ), - '#' - ), - additionalContent: ( - <> - - - - - ), - }, - ] } - actionProps={ { - name: 'paypal_connect_sandbox', - key: 'sandboxMode', - currentValue: settings.sandboxMode, - callback: updateFormValue, - } } - /> - ) } -
- ); -}; - -export default Sandbox; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting.js deleted file mode 100644 index 82cd635dc..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting.js +++ /dev/null @@ -1,168 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { - Header, - Title, - Description, - AccordionSettingsBlock, - ToggleSettingsBlock, - ButtonSettingsBlock, -} from '../../../../ReusableComponents/SettingsBlocks'; -import SettingsBlock from '../../../../ReusableComponents/SettingsBlocks/SettingsBlock'; - -const Troubleshooting = ( { updateFormValue, settings } ) => { - return ( - - - -
- - { __( - 'Subscribed PayPal webhooks', - 'woocommerce-paypal-payments' - ) } - - - { __( - 'The following PayPal webhooks are subscribed. More information about the webhooks is available in the', - 'woocommerce-paypal-payments' - ) }{ ' ' } - - { __( - 'Webhook Status documentation', - 'woocommerce-paypal-payments' - ) } - - . - -
- -
- - - console.log( - 'Resubscribe webhooks', - 'woocommerce-paypal-payments' - ), - value: __( - 'Resubscribe webhooks', - 'woocommerce-paypal-payments' - ), - } } - /> - - - console.log( - 'Simulate webhooks', - 'woocommerce-paypal-payments' - ), - value: __( - 'Simulate webhooks', - 'woocommerce-paypal-payments' - ), - } } - /> -
- ); -}; - -const hooksExampleData = () => { - return { - url: 'https://www.rt3.tech/wordpress/paypal-ux-testin/index.php?rest_route=/paypal/v1/incoming', - hooks: [ - 'billing plan pricing-change activated', - 'billing plan updated', - 'billing subscription cancelled', - 'catalog product updated', - 'checkout order approved', - 'checkout order completed', - 'checkout payment-approval reversed', - 'payment authorization voided', - 'payment capture completed', - 'payment capture denied', - 'payment capture pending', - 'payment capture refunded', - 'payment capture reversed', - 'payment order cancelled', - 'payment sale completed', - 'payment sale refunded', - 'vault payment-token created', - 'vault payment-token deleted', - ], - }; -}; - -const HooksTable = ( { data } ) => { - return ( - - - - - - - - - - - - - -
- { __( 'URL', 'woocommerce-paypal-payments' ) } - - { __( - 'Tracked events', - 'woocommerce-paypal-payments' - ) } -
{ data?.url } - { data.hooks.map( ( hook, index ) => ( - - { hook }{ ' ' } - { index !== data.hooks.length - 1 && ',' } - - ) ) } -
- ); -}; - -export default Troubleshooting; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/HooksTableBlock.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/HooksTableBlock.js new file mode 100644 index 000000000..5a057023c --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/HooksTableBlock.js @@ -0,0 +1,50 @@ +import { __ } from '@wordpress/i18n'; +import { CommonHooks } from '../../../../../../data'; +import { + SettingsBlock, + Title, +} from '../../../../../ReusableComponents/SettingsBlocks'; + +const HooksTableBlock = () => { + const { webhooks } = CommonHooks.useWebhooks(); + const { url, events } = webhooks; + + if ( ! url || ! events?.length ) { + return
...
; + } + + return ( + + + + + ); +}; + +const WebhookUrl = ( { url } ) => { + return ( +
+ + { __( 'Notification URL', 'woocommerce-paypal-payments' ) } + +

{ url }

+
+ ); +}; + +const WebhookEvents = ( { events } ) => { + return ( +
+ + { __( 'Subscribed Events', 'woocommerce-paypal-payments' ) } + +
    + { events.map( ( event, index ) => ( +
  • { event }
  • + ) ) } +
+
+ ); +}; + +export default HooksTableBlock; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/ResubscribeBlock.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/ResubscribeBlock.js new file mode 100644 index 000000000..c309681bc --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/ResubscribeBlock.js @@ -0,0 +1,73 @@ +import { useState } from '@wordpress/element'; +import { STORE_NAME } from '../../../../../../data/common'; +import { ButtonSettingsBlock } from '../../../../../ReusableComponents/SettingsBlocks'; +import { __ } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import { + NOTIFICATION_ERROR, + NOTIFICATION_SUCCESS, +} from '../../../../../ReusableComponents/Icons'; + +const ResubscribeBlock = () => { + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const [ resubscribing, setResubscribing ] = useState( false ); + + const { resubscribeWebhooks } = useDispatch( STORE_NAME ); + + const startResubscribingWebhooks = async () => { + setResubscribing( true ); + try { + await resubscribeWebhooks(); + } catch ( error ) { + setResubscribing( false ); + createErrorNotice( + __( + 'Operation failed. Check WooCommerce logs for more details.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_ERROR, + } + ); + return; + } + + setResubscribing( false ); + createSuccessNotice( + __( + 'Webhooks were successfully re-subscribed.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_SUCCESS, + } + ); + }; + + return ( + startResubscribingWebhooks(), + value: __( + 'Resubscribe webhooks', + 'woocommerce-paypal-payments' + ), + } } + /> + ); +}; + +export default ResubscribeBlock; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/SimulationBlock.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/SimulationBlock.js new file mode 100644 index 000000000..075113ddf --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/SimulationBlock.js @@ -0,0 +1,126 @@ +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { ButtonSettingsBlock } from '../../../../../ReusableComponents/SettingsBlocks'; +import { useDispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import { CommonHooks } from '../../../../../../data'; +import { + NOTIFICATION_ERROR, + NOTIFICATION_SUCCESS, +} from '../../../../../ReusableComponents/Icons'; + +const SimulationBlock = () => { + const { + createSuccessNotice, + createInfoNotice, + createErrorNotice, + removeNotice, + } = useDispatch( noticesStore ); + const { startWebhookSimulation, checkWebhookSimulationState } = + CommonHooks.useWebhooks(); + const [ simulating, setSimulating ] = useState( false ); + const sleep = ( ms ) => { + return new Promise( ( resolve ) => setTimeout( resolve, ms ) ); + }; + const startSimulation = async ( maxRetries ) => { + const webhookInfoNoticeId = 'paypal-webhook-simulation-info-notice'; + const triggerWebhookInfoNotice = () => { + createInfoNotice( + __( + 'Waiting for the webhook to arrive…', + 'woocommerce-paypal-payments' + ), + { + id: webhookInfoNoticeId, + } + ); + }; + + const stopSimulation = () => { + removeNotice( webhookInfoNoticeId ); + setSimulating( false ); + }; + + setSimulating( true ); + + triggerWebhookInfoNotice(); + + try { + await startWebhookSimulation(); + } catch ( error ) { + console.error( error ); + setSimulating( false ); + createErrorNotice( + __( + 'Operation failed. Check WooCommerce logs for more details.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_ERROR, + } + ); + return; + } + + for ( let i = 0; i < maxRetries; i++ ) { + await sleep( 2000 ); + + const simulationStateResponse = await checkWebhookSimulationState(); + try { + if ( ! simulationStateResponse.success ) { + console.error( + 'Simulation state query failed: ' + + simulationStateResponse?.data + ); + continue; + } + + if ( simulationStateResponse?.data?.state === 'received' ) { + createSuccessNotice( + __( + 'The webhook was received successfully.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_SUCCESS, + } + ); + stopSimulation(); + return; + } + removeNotice( webhookInfoNoticeId ); + triggerWebhookInfoNotice(); + } catch ( error ) { + console.error( error ); + } + } + stopSimulation(); + createErrorNotice( + __( + 'Looks like the webhook cannot be received. Check that your website is accessible from the internet.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_ERROR, + } + ); + }; + + return ( + startSimulation( 30 ), + value: __( 'Simulate webhooks', 'woocommerce-paypal-payments' ), + } } + /> + ); +}; +export default SimulationBlock; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/Troubleshooting.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/Troubleshooting.js new file mode 100644 index 000000000..4dc0d1fc6 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/Troubleshooting.js @@ -0,0 +1,69 @@ +import { __ } from '@wordpress/i18n'; +import { + AccordionSettingsBlock, + Description, + Header, + Title, + ToggleSettingsBlock, +} from '../../../../../ReusableComponents/SettingsBlocks'; +import SettingsBlock from '../../../../../ReusableComponents/SettingsBlocks/SettingsBlock'; + +import SimulationBlock from './SimulationBlock'; +import ResubscribeBlock from './ResubscribeBlock'; +import HooksTableBlock from './HooksTableBlock'; + +const Troubleshooting = ( { updateFormValue, settings } ) => { + return ( + + + +
+ + { __( 'Webhooks', 'woocommerce-paypal-payments' ) } + + + { __( + 'The following PayPal webhooks are subscribed. More information about the webhooks is available in the', + 'woocommerce-paypal-payments' + ) }{ ' ' } + + { __( + 'Webhook Status documentation', + 'woocommerce-paypal-payments' + ) } + + . + +
+ + + +
+
+ ); +}; + +export default Troubleshooting; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ConnectionStatus.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ConnectionStatus.js index b1018d44c..c765d8738 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ConnectionStatus.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ConnectionStatus.js @@ -1,13 +1,13 @@ import { __ } from '@wordpress/i18n'; import SettingsCard from '../../../ReusableComponents/SettingsCard'; -import ConnectionInfo, { - connectionStatusDataDefault, -} from '../../../ReusableComponents/ConnectionInfo'; +import { CommonHooks } from '../../../../data'; import TitleBadge, { TITLE_BADGE_NEGATIVE, TITLE_BADGE_POSITIVE, } from '../../../ReusableComponents/TitleBadge'; +import ConnectionInfo from '../../../ReusableComponents/ConnectionInfo'; const ConnectionStatus = () => { + const { merchant } = CommonHooks.useMerchantInfo(); return ( {
- { connectionStatusDataDefault.connectionStatus ? ( + { merchant.isConnected ? ( { ) }
- { connectionStatusDataDefault.connectionStatus && ( - + { merchant.isConnected && ( + ) }
diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ExpertSettings.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ExpertSettings.js index 56a8e63c6..0bc2914f3 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ExpertSettings.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ExpertSettings.js @@ -4,8 +4,8 @@ import { Content, ContentWrapper, } from '../../../ReusableComponents/SettingsBlocks'; -import Sandbox from './Blocks/Sandbox'; -import Troubleshooting from './Blocks/Troubleshooting'; +import ConnectionDetails from './Blocks/ConnectionDetails'; +import Troubleshooting from './Blocks/Troubleshooting/Troubleshooting'; import PaypalSettings from './Blocks/PaypalSettings'; import OtherSettings from './Blocks/OtherSettings'; @@ -27,7 +27,7 @@ const ExpertSettings = ( { updateFormValue, settings } ) => { > - diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabStyling.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabStyling.js deleted file mode 100644 index 7ddd8be7a..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabStyling.js +++ /dev/null @@ -1,336 +0,0 @@ -import { __, sprintf } from '@wordpress/i18n'; -import { SelectControl, RadioControl } from '@wordpress/components'; -import { PayPalCheckboxGroup } from '../../ReusableComponents/Fields'; -import { useState, useMemo, useEffect } from '@wordpress/element'; -import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js'; - -import { - defaultLocationSettings, - paymentMethodOptions, - colorOptions, - shapeOptions, - buttonLayoutOptions, - buttonLabelOptions, -} from '../../../data/settings/tab-styling-data'; - -const TabStyling = () => { - const [ location, setLocation ] = useState( 'cart' ); - const [ canRender, setCanRender ] = useState( false ); - const [ locationSettings, setLocationSettings ] = useState( { - ...defaultLocationSettings, - } ); - - // Sometimes buttons won't render. This fixes the timing problem. - useEffect( () => { - const handleDOMContentLoaded = () => setCanRender( true ); - if ( - document.readyState === 'interactive' || - document.readyState === 'complete' - ) { - handleDOMContentLoaded(); - } else { - document.addEventListener( - 'DOMContentLoaded', - handleDOMContentLoaded - ); - } - }, [] ); - - const currentLocationSettings = useMemo( () => { - return locationSettings[ location ]; - }, [ location, locationSettings ] ); - - const locationOptions = useMemo( () => { - return Object.keys( locationSettings ).reduce( - ( locationOptionsData, key ) => { - locationOptionsData.push( { - value: locationSettings[ key ].value, - label: locationSettings[ key ].label, - } ); - - return locationOptionsData; - }, - [] - ); - }, [] ); - - const updateButtonSettings = ( key, value ) => { - setLocationSettings( { - ...locationSettings, - [ location ]: { - ...currentLocationSettings, - settings: { - ...currentLocationSettings.settings, - [ key ]: value, - }, - }, - } ); - }; - - const updateButtonStyle = ( key, value ) => { - setLocationSettings( { - ...locationSettings, - [ location ]: { - ...currentLocationSettings, - settings: { - ...currentLocationSettings.settings, - style: { - ...currentLocationSettings.settings.style, - [ key ]: value, - }, - }, - }, - } ); - }; - - if ( ! canRender ) { - return <>; - } - - return ( -
-
- - - - - - - - - - -
-
-
- -
-
-
- ); -}; - -const TabStylingSection = ( props ) => { - let sectionTitleClassName = 'ppcp-r-styling__section'; - - if ( props?.className ) { - sectionTitleClassName += ` ${ props.className }`; - } - - return ( -
- { props.title } - { props?.description && ( -

- ) } - { props.children } -

- ); -}; - -const SectionIntro = ( { location } ) => { - const { description, descriptionLink } = - defaultLocationSettings[ location ]; - const buttonStyleDescription = sprintf( description, descriptionLink ); - - return ( - - ); -}; - -const SectionLocations = ( { locationOptions, location, setLocation } ) => { - return ( - - setLocation( newLocation ) } - label={ __( 'Locations', 'woocommerce-paypal-payments' ) } - options={ locationOptions } - /> - - ); -}; - -const SectionPaymentMethods = ( { - locationSettings, - updateButtonSettings, -} ) => { - return ( - -
- - updateButtonSettings( 'paymentMethods', newValue ) - } - currentValue={ locationSettings.settings.paymentMethods } - /> -
-
- ); -}; - -const SectionButtonLayout = ( { locationSettings, updateButtonStyle } ) => { - const buttonLayoutIsAllowed = - locationSettings.settings.style?.layout && - locationSettings.settings.style?.tagline === false; - return ( - buttonLayoutIsAllowed && ( - - - updateButtonStyle( 'layout', newValue ) - } - selected={ locationSettings.settings.style.layout } - options={ buttonLayoutOptions } - /> - - ) - ); -}; - -const SectionButtonShape = ( { locationSettings, updateButtonStyle } ) => { - return ( - - - updateButtonStyle( 'shape', newValue ) - } - selected={ locationSettings.settings.style.shape } - options={ shapeOptions } - /> - - ); -}; - -const SectionButtonLabel = ( { locationSettings, updateButtonStyle } ) => { - return ( - - - updateButtonStyle( 'label', newValue ) - } - value={ locationSettings.settings.style.label } - label={ __( 'Button Label', 'woocommerce-paypal-payments' ) } - options={ buttonLabelOptions } - /> - - ); -}; - -const SectionButtonColor = ( { locationSettings, updateButtonStyle } ) => { - return ( - - - updateButtonStyle( 'color', newValue ) - } - value={ locationSettings.settings.style.color } - options={ colorOptions } - /> - - ); -}; - -const SectionButtonTagline = ( { locationSettings, updateButtonStyle } ) => { - const taglineIsAllowed = - locationSettings.settings.style.hasOwnProperty( 'tagline' ) && - locationSettings.settings.style?.layout === 'horizontal'; - - return ( - taglineIsAllowed && ( - - { - updateButtonStyle( 'tagline', newValue ); - } } - currentValue={ locationSettings.settings.style.tagline } - /> - - ) - ); -}; - -const SectionButtonPreview = ( { locationSettings } ) => { - return ( - - - Error - - - ); -}; - -export default TabStyling; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/SendOnlyMessage.js b/modules/ppcp-settings/resources/js/Components/Screens/SendOnlyMessage.js new file mode 100644 index 000000000..676b691eb --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/SendOnlyMessage.js @@ -0,0 +1,54 @@ +import { __, sprintf } from '@wordpress/i18n'; + +import Container from '../ReusableComponents/Container'; +import SettingsCard from '../ReusableComponents/SettingsCard'; +import SettingsNavigation from './Settings/Components/Navigation'; + +const SendOnlyMessage = () => { + const settingsPageUrl = '/wp-admin/admin.php?page=wc-settings'; + + return ( + <> + + + +

+ { __( + 'Your current WooCommerce store location is in a "send-only" country, according to PayPal\'s policies', + 'woocommerce-paypal-payments' + ) } +

+

+ { __( + 'Since receiving payments is essential for using the PayPal Payments extension, you are unable to connect your PayPal account while operating from a "send-only" country.', + 'woocommerce-paypal-payments' + ) } +

+

update your WooCommerce store location to a supported region and connect a PayPal account eligible for receiving payments.', + 'woocommerce-paypal-payments' + ), + settingsPageUrl + ), + } } + /> + + + + ); +}; + +export default SendOnlyMessage; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings.js deleted file mode 100644 index bc79b34b3..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Settings.js +++ /dev/null @@ -1,51 +0,0 @@ -import { useEffect, useMemo } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import classNames from 'classnames'; - -import { OnboardingHooks } from '../../data'; -import SpinnerOverlay from '../ReusableComponents/SpinnerOverlay'; - -import Onboarding from './Onboarding/Onboarding'; -import SettingsScreen from './SettingsScreen'; - -const Settings = () => { - const onboardingProgress = OnboardingHooks.useSteps(); - - // Disable the "Changes you made might not be saved" browser warning. - useEffect( () => { - const suppressBeforeUnload = ( event ) => { - event.stopImmediatePropagation(); - return undefined; - }; - - window.addEventListener( 'beforeunload', suppressBeforeUnload ); - - return () => { - window.removeEventListener( 'beforeunload', suppressBeforeUnload ); - }; - }, [] ); - - const wrapperClass = classNames( 'ppcp-r-app', { - loading: ! onboardingProgress.isReady, - } ); - - const Content = useMemo( () => { - if ( ! onboardingProgress.isReady ) { - return ( - - ); - } - - if ( ! onboardingProgress.completed ) { - return ; - } - - return ; - }, [ onboardingProgress ] ); - - return

{ Content }
; -}; - -export default Settings; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Navigation.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Navigation.js new file mode 100644 index 000000000..9023a1f9b --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Navigation.js @@ -0,0 +1,36 @@ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +import { CommonHooks, StylingHooks } from '../../../../data'; +import TopNavigation from '../../../ReusableComponents/TopNavigation'; +import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; + +const SettingsNavigation = () => { + const { withActivity } = CommonHooks.useBusyState(); + + // Todo: Implement other stores here. + const { persist: persistStyling } = StylingHooks.useStore(); + + const handleSaveClick = () => { + // Todo: Add other stores here. + withActivity( + 'persist-styling', + 'Save styling details', + persistStyling + ); + }; + + const title = __( 'PayPal Payments', 'woocommerce-paypal-payments' ); + + return ( + + + + + + ); +}; + +export default SettingsNavigation; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonColor.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonColor.js new file mode 100644 index 000000000..29b98069c --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonColor.js @@ -0,0 +1,20 @@ +import { __ } from '@wordpress/i18n'; + +import { StylingHooks } from '../../../../../../data'; +import { SelectStylingSection } from '../Layout'; + +const SectionButtonColor = ( { location } ) => { + const { color, setColor, choices } = StylingHooks.useColorProps( location ); + + return ( + + ); +}; + +export default SectionButtonColor; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonLabel.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonLabel.js new file mode 100644 index 000000000..de7c1a103 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonLabel.js @@ -0,0 +1,20 @@ +import { __ } from '@wordpress/i18n'; + +import { StylingHooks } from '../../../../../../data'; +import { SelectStylingSection } from '../Layout'; + +const SectionButtonLabel = ( { location } ) => { + const { label, setLabel, choices } = StylingHooks.useLabelProps( location ); + + return ( + + ); +}; + +export default SectionButtonLabel; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonLayout.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonLayout.js new file mode 100644 index 000000000..1a284f72a --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonLayout.js @@ -0,0 +1,29 @@ +import { __ } from '@wordpress/i18n'; + +import { StylingHooks } from '../../../../../../data'; +import { RadiobuttonStylingSection } from '../Layout'; +import { Tagline } from './index'; + +const SectionButtonLayout = ( { location } ) => { + const { isAvailable, layout, setLayout, choices } = + StylingHooks.useLayoutProps( location ); + + if ( ! isAvailable ) { + return null; + } + + return ( + <> + + + + ); +}; + +export default SectionButtonLayout; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonShape.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonShape.js new file mode 100644 index 000000000..71bc5bebe --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/ButtonShape.js @@ -0,0 +1,20 @@ +import { __ } from '@wordpress/i18n'; + +import { StylingHooks } from '../../../../../../data'; +import { RadiobuttonStylingSection } from '../Layout'; + +const SectionButtonShape = ( { location } ) => { + const { shape, setShape, choices } = StylingHooks.useShapeProps( location ); + + return ( + + ); +}; + +export default SectionButtonShape; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/LocationSelector.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/LocationSelector.js new file mode 100644 index 000000000..a747e93b3 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Settings/Components/Styling/Content/LocationSelector.js @@ -0,0 +1,62 @@ +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { help } from '@wordpress/icons'; + +import { StylingHooks } from '../../../../../../data'; +import { + SelectStylingSection, + StylingSection, + CheckboxStylingSection, +} from '../Layout'; + +const LocationSelector = ( { location, setLocation } ) => { + const { choices, details, isActive, setActive } = + StylingHooks.useLocationProps( location ); + + const activateCheckbox = { + value: 'active', + label: __( + 'Enable payment methods in this location', + 'woocommerce-paypal-payments' + ), + }; + + return ( + <> + + + { details.link && ( + -
- } -
-
- ); -}; - -export default SettingsNavigation; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/tabs.js b/modules/ppcp-settings/resources/js/Components/Screens/tabs.js deleted file mode 100644 index c2f3b25e6..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/tabs.js +++ /dev/null @@ -1,35 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import TabOverview from './Overview/TabOverview'; -import TabPaymentMethods from './Overview/TabPaymentMethods'; -import TabSettings from './Overview/TabSettings'; -import TabStyling from './Overview/TabStyling'; - -export const getSettingsTabs = () => { - const tabs = []; - - tabs.push( { - name: 'overview', - title: __( 'Overview', 'woocommerce-paypal-payments' ), - component: , - } ); - - tabs.push( { - name: 'payment-methods', - title: __( 'Payment Methods', 'woocommerce-paypal-payments' ), - component: , - } ); - - tabs.push( { - name: 'settings', - title: __( 'Settings', 'woocommerce-paypal-payments' ), - component: , - } ); - - tabs.push( { - name: 'styling', - title: __( 'Styling', 'woocommerce-paypal-payments' ), - component: , - } ); - - return tabs; -}; diff --git a/modules/ppcp-settings/resources/js/data/_example/README.md b/modules/ppcp-settings/resources/js/data/_example/README.md new file mode 100644 index 000000000..b97f6ca4c --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/README.md @@ -0,0 +1,45 @@ +# Store template + +This template contains all files for a Redux store. + +## New Store: Redux integration + +1. Copy this folder, give it a correct name. +2. Check each file for `` placeholders and `TODO` remarks. +3. Edit the main store-index file and add the relevant store integration there. +4. Check the debug-module, and add relevant debug code. + - Register the store in the `reset()` method. + +--- + +Main store-index: +`modules/ppcp-settings/resources/js/data/index.js` + +Sample store integration: +```js +import * as YourStore from './yourStore'; +// ... +YourStore.initStore(); +// ... +export const YourStoreHooks = YourStore.hooks; +// ... +export const YourStoreName = YourStore.STORE_NAME; +// ... +addDebugTools( window.ppcpSettings, [ ..., YourStoreName ] ); +``` + +--- + +### New Store: PHP integration + +1. Create the **REST endpoint** for hydrating and persisting data. + - `modules/ppcp-settings/src/Endpoint/YourStoreRestEndpoint.php` + - Extend from base class `RestEndpoint` +2. Create the **data model** class to manage the DB interaction. + - `modules/ppcp-settings/src/Data/YourStoreSettings.php` + - Extend from base class `AbstractDataModel` +3. Create relevant **DI services** for both files. + - `modules/ppcp-settings/services.php` +4. Register the REST endpoint in the **service module**. + - `modules/ppcp-settings/src/SettingsModule.php` + - Find the action `rest_api_init` diff --git a/modules/ppcp-settings/resources/js/data/_example/action-types.js b/modules/ppcp-settings/resources/js/data/_example/action-types.js new file mode 100644 index 000000000..6341087d6 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/action-types.js @@ -0,0 +1,18 @@ +/** + * Action Types: Define unique identifiers for actions across all store modules. + * + * @file + */ + +export default { + // Transient data. + SET_TRANSIENT: ':SET_TRANSIENT', + + // Persistent data. + SET_PERSISTENT: ':SET_PERSISTENT', + RESET: ':RESET', + HYDRATE: ':HYDRATE', + + // Controls - always start with "DO_". + DO_PERSIST_DATA: ':DO_PERSIST_DATA', +}; diff --git a/modules/ppcp-settings/resources/js/data/_example/actions.js b/modules/ppcp-settings/resources/js/data/_example/actions.js new file mode 100644 index 000000000..59d68d37c --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/actions.js @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * Special. Resets all values in the store to initial defaults. + * + * @return {Action} The action. + */ +export const reset = () => ( { type: ACTION_TYPES.RESET } ); + +/** + * Persistent. Set the full store details during app initialization. + * + * @param {{data: {}, flags?: {}}} payload + * @return {Action} The action. + */ +export const hydrate = ( payload ) => ( { + type: ACTION_TYPES.HYDRATE, + payload, +} ); + +/** + * Generic transient-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setTransient = ( prop, value ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { [ prop ]: value }, +} ); + +/** + * Generic persistent-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setPersistent = ( prop, value ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { [ prop ]: value }, +} ); + +/** + * Transient. Marks the store as "ready", i.e., fully initialized. + * + * @param {boolean} isReady + * @return {Action} The action. + */ +export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady ); + +/** + * Side effect. Triggers the persistence of store 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 }; +}; diff --git a/modules/ppcp-settings/resources/js/data/_example/constants.js b/modules/ppcp-settings/resources/js/data/_example/constants.js new file mode 100644 index 000000000..9caae616f --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/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/'; + +/** + * REST path to hydrate data of this module by loading data from the WP DB. + * + * Used by: Resolvers + * See: .php + * + * @type {string} + */ +export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/'; + +/** + * REST path to persist data of this module to the WP DB. + * + * Used by: Controls + * See: .php + * + * @type {string} + */ +export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/'; diff --git a/modules/ppcp-settings/resources/js/data/_example/controls.js b/modules/ppcp-settings/resources/js/data/_example/controls.js new file mode 100644 index 000000000..9295b62bc --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/controls.js @@ -0,0 +1,23 @@ +/** + * 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 } ) { + return await apiFetch( { + path: REST_PERSIST_PATH, + method: 'POST', + data, + } ); + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/_example/hooks.js b/modules/ppcp-settings/resources/js/data/_example/hooks.js new file mode 100644 index 000000000..b6878c2a9 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/hooks.js @@ -0,0 +1,50 @@ +/** + * 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 } from '@wordpress/data'; + +import { createHooksForStore } from '../utils'; +import { STORE_NAME } from './constants'; + +const useHooks = () => { + const { useTransient, usePersistent } = createHooksForStore( STORE_NAME ); + const { persist } = useDispatch( STORE_NAME ); + + // Read-only flags and derived state. + // Nothing here yet. + + // Transient accessors. + const [ isReady ] = useTransient( 'isReady' ); + + // Persistent accessors. + // TODO: Replace with real property. + const [ sampleValue, setSampleValue ] = usePersistent( 'sampleValue' ); + + return { + persist, + isReady, + sampleValue, + setSampleValue, + }; +}; + +export const useState = () => { + const { persist, isReady } = useHooks(); + return { persist, isReady }; +}; + +// TODO: Replace with real hook. +export const useSampleValue = () => { + const { sampleValue, setSampleValue } = useHooks(); + + return { + sampleValue, + setSampleValue, + }; +}; diff --git a/modules/ppcp-settings/resources/js/data/_example/index.js b/modules/ppcp-settings/resources/js/data/_example/index.js new file mode 100644 index 000000000..28c162f98 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/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/_example/reducer.js b/modules/ppcp-settings/resources/js/data/_example/reducer.js new file mode 100644 index 000000000..a858b719a --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/reducer.js @@ -0,0 +1,56 @@ +/** + * 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. + +// Transient: Values that are _not_ saved to the DB (like app lifecycle-flags). +const defaultTransient = Object.freeze( { + isReady: false, +} ); + +// Persistent: Values that are loaded from the DB. +const defaultPersistent = Object.freeze( { + // TODO: Add real DB properties here. + sampleValue: 'foo', +} ); + +// Reducer logic. + +const [ setTransient, setPersistent ] = createSetters( + defaultTransient, + defaultPersistent +); + +const reducer = 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 ) => { + const cleanState = setTransient( + setPersistent( state, defaultPersistent ), + defaultTransient + ); + + // Keep "read-only" details and initialization flags. + cleanState.isReady = true; + + return cleanState; + }, + + [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => + setPersistent( state, payload.data ), +} ); + +export default reducer; diff --git a/modules/ppcp-settings/resources/js/data/_example/resolvers.js b/modules/ppcp-settings/resources/js/data/_example/resolvers.js new file mode 100644 index 000000000..84ff1766a --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/resolvers.js @@ -0,0 +1,37 @@ +/** + * 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( + // TODO: Add the module name to the error message. + __( + 'Error retrieving details.', + 'woocommerce-paypal-payments' + ) + ); + } + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/_example/selectors.js b/modules/ppcp-settings/resources/js/data/_example/selectors.js new file mode 100644 index 000000000..14334fcf3 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/_example/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/common/action-types.js b/modules/ppcp-settings/resources/js/data/common/action-types.js index ac08cdcf7..f33e8a9ee 100644 --- a/modules/ppcp-settings/resources/js/data/common/action-types.js +++ b/modules/ppcp-settings/resources/js/data/common/action-types.js @@ -19,9 +19,13 @@ export default { // 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', - DO_PRODUCTION_LOGIN: 'COMMON:DO_PRODUCTION_LOGIN', + DO_DIRECT_API_AUTHENTICATION: 'COMMON:DO_DIRECT_API_AUTHENTICATION', + DO_OAUTH_AUTHENTICATION: 'COMMON:DO_OAUTH_AUTHENTICATION', + DO_DISCONNECT_MERCHANT: 'COMMON:DO_DISCONNECT_MERCHANT', + DO_GENERATE_ONBOARDING_URL: 'COMMON:DO_GENERATE_ONBOARDING_URL', DO_REFRESH_MERCHANT: 'COMMON:DO_REFRESH_MERCHANT', - DO_REFRESH_FEATURES: 'DO_REFRESH_FEATURES', + DO_REFRESH_FEATURES: 'COMMON:DO_REFRESH_FEATURES', + DO_RESUBSCRIBE_WEBHOOKS: 'COMMON:DO_RESUBSCRIBE_WEBHOOKS', + DO_START_WEBHOOK_SIMULATION: 'COMMON:DO_START_WEBHOOK_SIMULATION', + DO_CHECK_WEBHOOK_SIMULATION: 'COMMON:DO_CHECK_WEBHOOK_SIMULATION', }; diff --git a/modules/ppcp-settings/resources/js/data/common/actions.js b/modules/ppcp-settings/resources/js/data/common/actions.js index ccbf34ce0..cf7837d54 100644 --- a/modules/ppcp-settings/resources/js/data/common/actions.js +++ b/modules/ppcp-settings/resources/js/data/common/actions.js @@ -7,7 +7,7 @@ * @file */ -import { dispatch, select } from '@wordpress/data'; +import { select } from '@wordpress/data'; import ACTION_TYPES from './action-types'; import { STORE_NAME } from './constants'; @@ -36,27 +36,46 @@ export const hydrate = ( payload ) => ( { payload, } ); +/** + * Generic transient-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setTransient = ( prop, value ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { [ prop ]: value }, +} ); + +/** + * Generic persistent-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setPersistent = ( prop, value ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { [ prop ]: value }, +} ); + /** * 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 }, -} ); +export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady ); /** - * Transient. Changes the "saving" flag. + * Transient. Sets the active settings tab. * - * @param {boolean} isSaving + * @param {string} activeModal * @return {Action} The action. */ -export const setIsSaving = ( isSaving ) => ( { - type: ACTION_TYPES.SET_TRANSIENT, - payload: { isSaving }, -} ); +export const setActiveModal = ( activeModal ) => + setTransient( 'activeModal', activeModal ); /** * Transient (Activity): Marks the start of an async activity @@ -96,10 +115,8 @@ export const stopActivity = ( id ) => ( { * @param {boolean} useSandbox * @return {Action} The action. */ -export const setSandboxMode = ( useSandbox ) => ( { - type: ACTION_TYPES.SET_PERSISTENT, - payload: { useSandbox }, -} ); +export const setSandboxMode = ( useSandbox ) => + setPersistent( 'useSandbox', useSandbox ); /** * Persistent. Toggles the "Manual Connection" mode on or off. @@ -107,32 +124,8 @@ export const setSandboxMode = ( useSandbox ) => ( { * @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 }, -} ); +export const setManualConnectionMode = ( useManualConnection ) => + setPersistent( 'useManualConnection', useManualConnection ); /** * Side effect. Saves the persistent details to the WP database. @@ -150,8 +143,12 @@ export const persist = function* () { * * @return {Action} The action. */ -export const connectToSandbox = function* () { - return yield { type: ACTION_TYPES.DO_SANDBOX_LOGIN }; +export const sandboxOnboardingUrl = function* () { + return yield { + type: ACTION_TYPES.DO_GENERATE_ONBOARDING_URL, + useSandbox: true, + products: [ 'EXPRESS_CHECKOUT' ], + }; }; /** @@ -160,27 +157,74 @@ export const connectToSandbox = function* () { * @param {string[]} products Which products/features to display in the ISU popup. * @return {Action} The action. */ -export const connectToProduction = function* ( products = [] ) { - return yield { type: ACTION_TYPES.DO_PRODUCTION_LOGIN, products }; +export const productionOnboardingUrl = function* ( products = [] ) { + return yield { + type: ACTION_TYPES.DO_GENERATE_ONBOARDING_URL, + useSandbox: false, + products, + }; }; /** - * Side effect. Initiates a manual connection attempt using the provided client ID and secret. + * Side effect. Initiates a direct connection attempt using the provided client ID and secret. * + * This action accepts parameters instead of fetching data from the Redux state because the + * values (ID and secret) are not managed by a central redux store, but might come from private + * component state. + * + * @param {string} clientId - AP client ID (always 80-characters, starting with "A"). + * @param {string} clientSecret - API client secret. + * @param {boolean} useSandbox - Whether the credentials are for a sandbox account. * @return {Action} The action. */ -export const connectViaIdAndSecret = function* () { - const { clientId, clientSecret, useSandbox } = - yield select( STORE_NAME ).persistentData(); - +export const authenticateWithCredentials = function* ( + clientId, + clientSecret, + useSandbox +) { return yield { - type: ACTION_TYPES.DO_MANUAL_CONNECTION, + type: ACTION_TYPES.DO_DIRECT_API_AUTHENTICATION, clientId, clientSecret, useSandbox, }; }; +/** + * Side effect. Completes the ISU login by authenticating the user via the one time sharedId and + * authCode provided by PayPal. + * + * This action accepts parameters instead of fetching data from the Redux state because all + * parameters are dynamically generated during the authentication process, and not managed by our + * Redux store. + * + * @param {string} sharedId - OAuth client ID; called "sharedId" to prevent confusion with the API client ID. + * @param {string} authCode - OAuth authorization code provided during onboarding. + * @param {boolean} useSandbox - Whether the credentials are for a sandbox account. + * @return {Action} The action. + */ +export const authenticateWithOAuth = function* ( + sharedId, + authCode, + useSandbox +) { + return yield { + type: ACTION_TYPES.DO_OAUTH_AUTHENTICATION, + sharedId, + authCode, + useSandbox, + }; +}; + +/** + * Side effect. Checks webhook simulation. + * + * @return {Action} The action. + */ +export const disconnectMerchant = function* () { + return yield { type: ACTION_TYPES.DO_DISCONNECT_MERCHANT }; +}; + /** * Side effect. Clears and refreshes the merchant data via a REST request. * @@ -214,3 +258,48 @@ export const refreshFeatureStatuses = function* () { return result; }; + +/** + * Persistent. Changes the "webhooks" value. + * + * @param {string} webhooks + * @return {Action} The action. + */ +export const setWebhooks = ( webhooks ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { webhooks }, +} ); + +/** + * Side effect + * Refreshes subscribed webhooks via a REST request + * + * @return {Action} The action. + */ +export const resubscribeWebhooks = function* () { + const result = yield { type: ACTION_TYPES.DO_RESUBSCRIBE_WEBHOOKS }; + + if ( result && result.success ) { + yield hydrate( result ); + } + + return result; +}; + +/** + * Side effect. Starts webhook simulation. + * + * @return {Action} The action. + */ +export const startWebhookSimulation = function* () { + return yield { type: ACTION_TYPES.DO_START_WEBHOOK_SIMULATION }; +}; + +/** + * Side effect. Checks webhook simulation. + * + * @return {Action} The action. + */ +export const checkWebhookSimulationState = function* () { + return yield { type: ACTION_TYPES.DO_CHECK_WEBHOOK_SIMULATION }; +}; diff --git a/modules/ppcp-settings/resources/js/data/common/constants.js b/modules/ppcp-settings/resources/js/data/common/constants.js index c67b1fef0..86bd670db 100644 --- a/modules/ppcp-settings/resources/js/data/common/constants.js +++ b/modules/ppcp-settings/resources/js/data/common/constants.js @@ -35,14 +35,37 @@ export const REST_HYDRATE_MERCHANT_PATH = '/wc/v3/wc_paypal/common/merchant'; export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/common'; /** - * REST path to perform the manual connection check, using client ID and secret, + * REST path to perform the manual connection authentication, using client ID and secret. * * Used by: Controls - * See: ConnectManualRestEndpoint.php + * See: AuthenticateRestEndpoint.php * * @type {string} */ -export const REST_MANUAL_CONNECTION_PATH = '/wc/v3/wc_paypal/connect_manual'; +export const REST_DIRECT_AUTHENTICATION_PATH = + '/wc/v3/wc_paypal/authenticate/direct'; + +/** + * REST path to perform the OAuth authentication check, using shared ID and authCode. + * + * Used by: Controls + * See: AuthenticateRestEndpoint.php + * + * @type {string} + */ +export const REST_OAUTH_AUTHENTICATION_PATH = + '/wc/v3/wc_paypal/authenticate/oauth'; + +/** + * REST path to disconnect the current merchant from PayPal. + * + * Used by: Controls + * See: AuthenticateRestEndpoint.php + * + * @type {string} + */ +export const REST_DISCONNECT_MERCHANT_PATH = + '/wc/v3/wc_paypal/authenticate/disconnect'; /** * REST path to generate an ISU URL for the PayPal-login. @@ -54,6 +77,26 @@ export const REST_MANUAL_CONNECTION_PATH = '/wc/v3/wc_paypal/connect_manual'; */ export const REST_CONNECTION_URL_PATH = '/wc/v3/wc_paypal/login_link'; +/** + * REST path to fetch webhooks data or resubscribe webhooks. + * + * Used by: Controls + * See: WebhookSettingsEndpoint.php + * + * @type {string} + */ +export const REST_WEBHOOKS = '/wc/v3/wc_paypal/webhooks'; + +/** + * REST path to start webhook simulation and observe the state. + * + * Used by: Controls + * See: WebhookSettingsEndpoint.php + * + * @type {string} + */ +export const REST_WEBHOOKS_SIMULATE = '/wc/v3/wc_paypal/webhooks/simulate'; + /** * REST path to refresh the feature status. * @@ -62,5 +105,4 @@ export const REST_CONNECTION_URL_PATH = '/wc/v3/wc_paypal/login_link'; * * @type {string} */ -export const REST_REFRESH_FEATURES_PATH = - '/wc/v3/wc_paypal/refresh-feature-status'; +export const REST_REFRESH_FEATURES_PATH = '/wc/v3/wc_paypal/refresh-features'; diff --git a/modules/ppcp-settings/resources/js/data/common/controls.js b/modules/ppcp-settings/resources/js/data/common/controls.js index a088660b9..a318e2116 100644 --- a/modules/ppcp-settings/resources/js/data/common/controls.js +++ b/modules/ppcp-settings/resources/js/data/common/controls.js @@ -11,10 +11,14 @@ import apiFetch from '@wordpress/api-fetch'; import { REST_PERSIST_PATH, - REST_MANUAL_CONNECTION_PATH, REST_CONNECTION_URL_PATH, REST_HYDRATE_MERCHANT_PATH, REST_REFRESH_FEATURES_PATH, + REST_DIRECT_AUTHENTICATION_PATH, + REST_OAUTH_AUTHENTICATION_PATH, + REST_DISCONNECT_MERCHANT_PATH, + REST_WEBHOOKS, + REST_WEBHOOKS_SIMULATE, } from './constants'; import ACTION_TYPES from './action-types'; @@ -31,15 +35,15 @@ export const controls = { } }, - async [ ACTION_TYPES.DO_SANDBOX_LOGIN ]() { + async [ ACTION_TYPES.DO_GENERATE_ONBOARDING_URL ]( { + products, + useSandbox, + } ) { try { return apiFetch( { path: REST_CONNECTION_URL_PATH, method: 'POST', - data: { - environment: 'sandbox', - products: [ 'EXPRESS_CHECKOUT' ], // Sandbox always uses EXPRESS_CHECKOUT. - }, + data: { useSandbox, products }, } ); } catch ( e ) { return { @@ -49,32 +53,14 @@ export const controls = { } }, - async [ ACTION_TYPES.DO_PRODUCTION_LOGIN ]( { products } ) { - try { - return apiFetch( { - path: REST_CONNECTION_URL_PATH, - method: 'POST', - data: { - environment: 'production', - products, - }, - } ); - } catch ( e ) { - return { - success: false, - error: e, - }; - } - }, - - async [ ACTION_TYPES.DO_MANUAL_CONNECTION ]( { + async [ ACTION_TYPES.DO_DIRECT_API_AUTHENTICATION ]( { clientId, clientSecret, useSandbox, } ) { try { return await apiFetch( { - path: REST_MANUAL_CONNECTION_PATH, + path: REST_DIRECT_AUTHENTICATION_PATH, method: 'POST', data: { clientId, @@ -90,6 +76,36 @@ export const controls = { } }, + async [ ACTION_TYPES.DO_OAUTH_AUTHENTICATION ]( { + sharedId, + authCode, + useSandbox, + } ) { + try { + return await apiFetch( { + path: REST_OAUTH_AUTHENTICATION_PATH, + method: 'POST', + data: { + sharedId, + authCode, + useSandbox, + }, + } ); + } catch ( e ) { + return { + success: false, + error: e, + }; + } + }, + + async [ ACTION_TYPES.DO_DISCONNECT_MERCHANT ]() { + return await apiFetch( { + path: REST_DISCONNECT_MERCHANT_PATH, + method: 'POST', + } ); + }, + async [ ACTION_TYPES.DO_REFRESH_MERCHANT ]() { try { return await apiFetch( { path: REST_HYDRATE_MERCHANT_PATH } ); @@ -115,4 +131,24 @@ export const controls = { }; } }, + + async [ ACTION_TYPES.DO_RESUBSCRIBE_WEBHOOKS ]() { + return await apiFetch( { + method: 'POST', + path: REST_WEBHOOKS, + } ); + }, + + async [ ACTION_TYPES.DO_START_WEBHOOK_SIMULATION ]() { + return await apiFetch( { + method: 'POST', + path: REST_WEBHOOKS_SIMULATE, + } ); + }, + + async [ ACTION_TYPES.DO_CHECK_WEBHOOK_SIMULATION ]() { + return await apiFetch( { + path: REST_WEBHOOKS_SIMULATE, + } ); + }, }; diff --git a/modules/ppcp-settings/resources/js/data/common/hooks.js b/modules/ppcp-settings/resources/js/data/common/hooks.js index 8eaaa3924..796d1c50c 100644 --- a/modules/ppcp-settings/resources/js/data/common/hooks.js +++ b/modules/ppcp-settings/resources/js/data/common/hooks.js @@ -10,49 +10,48 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { useCallback } from '@wordpress/element'; +import { createHooksForStore } from '../utils'; 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 { useTransient, usePersistent } = createHooksForStore( STORE_NAME ); const { persist, - setSandboxMode, - setManualConnectionMode, - setClientId, - setClientSecret, - connectToSandbox, - connectToProduction, - connectViaIdAndSecret, + sandboxOnboardingUrl, + productionOnboardingUrl, + authenticateWithCredentials, + authenticateWithOAuth, + startWebhookSimulation, + checkWebhookSimulationState, } = useDispatch( STORE_NAME ); // Transient accessors. - const isReady = useTransient( 'isReady' ); + const [ isReady ] = useTransient( 'isReady' ); + const [ activeModal, setActiveModal ] = useTransient( 'activeModal' ); // Persistent accessors. - const clientId = usePersistent( 'clientId' ); - const clientSecret = usePersistent( 'clientSecret' ); - const isSandboxMode = usePersistent( 'useSandbox' ); - const isManualConnectionMode = usePersistent( 'useManualConnection' ); - + const [ isSandboxMode, setSandboxMode ] = usePersistent( 'useSandbox' ); + const [ isManualConnectionMode, setManualConnectionMode ] = usePersistent( + 'useManualConnection' + ); const merchant = useSelect( ( select ) => select( STORE_NAME ).merchant(), [] ); + + // Read-only properties. const wooSettings = useSelect( ( select ) => select( STORE_NAME ).wooSettings(), [] ); + const features = useSelect( + ( select ) => select( STORE_NAME ).features(), + [] + ); + const webhooks = useSelect( + ( select ) => select( STORE_NAME ).webhooks(), + [] + ); const savePersistent = async ( setter, value ) => { setter( value ); @@ -61,6 +60,8 @@ const useHooks = () => { return { isReady, + activeModal, + setActiveModal, isSandboxMode, setSandboxMode: ( state ) => { return savePersistent( setSandboxMode, state ); @@ -69,53 +70,44 @@ const useHooks = () => { setManualConnectionMode: ( state ) => { return savePersistent( setManualConnectionMode, state ); }, - clientId, - setClientId: ( value ) => { - return savePersistent( setClientId, value ); - }, - clientSecret, - setClientSecret: ( value ) => { - return savePersistent( setClientSecret, value ); - }, - connectToSandbox, - connectToProduction, - connectViaIdAndSecret, + sandboxOnboardingUrl, + productionOnboardingUrl, + authenticateWithCredentials, + authenticateWithOAuth, merchant, wooSettings, + features, + webhooks, + startWebhookSimulation, + checkWebhookSimulationState, }; }; export const useSandbox = () => { - const { isSandboxMode, setSandboxMode, connectToSandbox } = useHooks(); + const { isSandboxMode, setSandboxMode, sandboxOnboardingUrl } = useHooks(); - return { isSandboxMode, setSandboxMode, connectToSandbox }; + return { isSandboxMode, setSandboxMode, sandboxOnboardingUrl }; }; export const useProduction = () => { - const { connectToProduction } = useHooks(); + const { productionOnboardingUrl } = useHooks(); - return { connectToProduction }; + return { productionOnboardingUrl }; }; -export const useManualConnection = () => { +export const useAuthentication = () => { const { isManualConnectionMode, setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - connectViaIdAndSecret, + authenticateWithCredentials, + authenticateWithOAuth, } = useHooks(); return { isManualConnectionMode, setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - connectViaIdAndSecret, + authenticateWithCredentials, + authenticateWithOAuth, }; }; @@ -125,8 +117,24 @@ export const useWooSettings = () => { return wooSettings; }; +export const useWebhooks = () => { + const { + webhooks, + setWebhooks, + registerWebhooks, + startWebhookSimulation, + checkWebhookSimulationState, + } = useHooks(); + return { + webhooks, + setWebhooks, + registerWebhooks, + startWebhookSimulation, + checkWebhookSimulationState, + }; +}; export const useMerchantInfo = () => { - const { merchant } = useHooks(); + const { isReady, merchant, features } = useHooks(); const { refreshMerchantData } = useDispatch( STORE_NAME ); const verifyLoginStatus = useCallback( async () => { @@ -141,11 +149,18 @@ export const useMerchantInfo = () => { }, [ refreshMerchantData, merchant ] ); return { + isReady, merchant, // Merchant details + features, // Eligible merchant features verifyLoginStatus, // Callback }; }; +export const useActiveModal = () => { + const { activeModal, setActiveModal } = useHooks(); + return { activeModal, setActiveModal }; +}; + // -- Not using the `useHooks()` data provider -- export const useBusyState = () => { diff --git a/modules/ppcp-settings/resources/js/data/common/reducer.js b/modules/ppcp-settings/resources/js/data/common/reducer.js index 7d3f5697f..559024728 100644 --- a/modules/ppcp-settings/resources/js/data/common/reducer.js +++ b/modules/ppcp-settings/resources/js/data/common/reducer.js @@ -15,6 +15,7 @@ import ACTION_TYPES from './action-types'; const defaultTransient = Object.freeze( { isReady: false, activities: new Map(), + activeModal: '', // Read only values, provided by the server via hydrate. merchant: Object.freeze( { @@ -22,19 +23,36 @@ const defaultTransient = Object.freeze( { isSandbox: false, id: '', email: '', + clientId: '', + clientSecret: '', } ), wooSettings: Object.freeze( { storeCountry: '', storeCurrency: '', } ), + + features: Object.freeze( { + save_paypal_and_venmo: { + enabled: false, + }, + advanced_credit_and_debit_cards: { + enabled: false, + }, + apple_pay: { + enabled: false, + }, + google_pay: { + enabled: false, + }, + } ), + + webhooks: Object.freeze( [] ), } ); const defaultPersistent = Object.freeze( { useSandbox: false, useManualConnection: false, - clientId: '', - clientSecret: '', } ); // Reducer logic. @@ -59,6 +77,8 @@ const commonReducer = createReducer( defaultTransient, defaultPersistent, { // Keep "read-only" details and initialization flags. cleanState.wooSettings = { ...state.wooSettings }; + cleanState.merchant = { ...state.merchant }; + cleanState.features = { ...state.features }; cleanState.isReady = true; return cleanState; @@ -82,22 +102,25 @@ const commonReducer = createReducer( defaultTransient, defaultPersistent, { [ ACTION_TYPES.DO_REFRESH_MERCHANT ]: ( state ) => ( { ...state, merchant: Object.freeze( { ...defaultTransient.merchant } ), + features: Object.freeze( { ...defaultTransient.features } ), } ), [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => { const newState = setPersistent( state, payload.data ); // Populate read-only properties. - [ 'wooSettings', 'merchant' ].forEach( ( key ) => { - if ( ! payload[ key ] ) { - return; - } + [ 'wooSettings', 'merchant', 'features', 'webhooks' ].forEach( + ( key ) => { + if ( ! payload[ key ] ) { + return; + } - newState[ key ] = Object.freeze( { - ...newState[ key ], - ...payload[ key ], - } ); - } ); + newState[ key ] = Object.freeze( { + ...newState[ key ], + ...payload[ key ], + } ); + } + ); return newState; }, diff --git a/modules/ppcp-settings/resources/js/data/common/resolvers.js b/modules/ppcp-settings/resources/js/data/common/resolvers.js index ceebca53f..2c1f0d3c2 100644 --- a/modules/ppcp-settings/resources/js/data/common/resolvers.js +++ b/modules/ppcp-settings/resources/js/data/common/resolvers.js @@ -12,7 +12,7 @@ import { dispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { apiFetch } from '@wordpress/data-controls'; -import { STORE_NAME, REST_HYDRATE_PATH } from './constants'; +import { STORE_NAME, REST_HYDRATE_PATH, REST_WEBHOOKS } from './constants'; export const resolvers = { /** @@ -21,6 +21,11 @@ export const resolvers = { *persistentData() { try { const result = yield apiFetch( { path: REST_HYDRATE_PATH } ); + const webhooks = yield apiFetch( { path: REST_WEBHOOKS } ); + + if ( webhooks.success && webhooks.data ) { + result.webhooks = webhooks.data; + } yield dispatch( STORE_NAME ).hydrate( result ); yield dispatch( STORE_NAME ).setIsReady( true ); diff --git a/modules/ppcp-settings/resources/js/data/common/selectors.js b/modules/ppcp-settings/resources/js/data/common/selectors.js index fde5d8c9e..4716550bb 100644 --- a/modules/ppcp-settings/resources/js/data/common/selectors.js +++ b/modules/ppcp-settings/resources/js/data/common/selectors.js @@ -16,8 +16,14 @@ export const persistentData = ( state ) => { }; export const transientData = ( state ) => { - const { data, merchant, wooSettings, ...transientState } = - getState( state ); + const { + data, + merchant, + features, + wooSettings, + webhooks, + ...transientState + } = getState( state ); return transientState || EMPTY_OBJ; }; @@ -30,6 +36,14 @@ export const merchant = ( state ) => { return getState( state ).merchant || EMPTY_OBJ; }; +export const features = ( state ) => { + return getState( state ).features || EMPTY_OBJ; +}; + export const wooSettings = ( state ) => { return getState( state ).wooSettings || EMPTY_OBJ; }; + +export const webhooks = ( state ) => { + return getState( state ).webhooks || EMPTY_OBJ; +}; diff --git a/modules/ppcp-settings/resources/js/data/configuration.js b/modules/ppcp-settings/resources/js/data/configuration.js new file mode 100644 index 000000000..0f3608552 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/configuration.js @@ -0,0 +1,10 @@ +export { BUSINESS_TYPES, PRODUCT_TYPES } from './onboarding/configuration'; + +export { + STYLING_LOCATIONS, + STYLING_PAYMENT_METHODS, + STYLING_LABELS, + STYLING_COLORS, + STYLING_LAYOUTS, + STYLING_SHAPES, +} from './styling/configuration'; diff --git a/modules/ppcp-settings/resources/js/data/debug.js b/modules/ppcp-settings/resources/js/data/debug.js index 6380c6d6a..49360be6d 100644 --- a/modules/ppcp-settings/resources/js/data/debug.js +++ b/modules/ppcp-settings/resources/js/data/debug.js @@ -1,10 +1,15 @@ -import { OnboardingStoreName, CommonStoreName } from './index'; +import { + OnboardingStoreName, + CommonStoreName, + PaymentStoreName, +} from './index'; export const addDebugTools = ( context, modules ) => { if ( ! context || ! context?.debug ) { return; } + // Dump the current state of all our Redux stores. context.dumpStore = async () => { /* eslint-disable no-console */ if ( ! console?.groupCollapsed ) { @@ -32,21 +37,47 @@ export const addDebugTools = ( context, modules ) => { /* eslint-enable no-console */ }; + // Reset all Redux stores to their initial state. context.resetStore = () => { - const stores = [ OnboardingStoreName, CommonStoreName ]; + const stores = []; + const { isConnected } = wp.data.select( CommonStoreName ).merchant(); + + if ( isConnected ) { + // Make sure the Onboarding wizard is "completed". + const onboarding = wp.data.dispatch( OnboardingStoreName ); + onboarding.setCompleted( true ); + onboarding.persist(); + + // Reset all stores, except for the onboarding store. + stores.push( CommonStoreName ); + // TODO: Add other stores here once they are available. + stores.push( PaymentStoreName ); + } else { + // Only reset the common & onboarding stores to restart the onboarding wizard. + stores.push( CommonStoreName ); + stores.push( OnboardingStoreName ); + } stores.forEach( ( storeName ) => { const store = wp.data.dispatch( storeName ); + // eslint-disable-next-line no-console + console.log( `Reset store: ${ storeName }...` ); + store.reset(); store.persist(); } ); }; - context.startOnboarding = () => { - const onboarding = wp.data.dispatch( OnboardingStoreName ); - onboarding.setCompleted( false ); - onboarding.setStep( 0 ); - onboarding.persist(); + // Disconnect the merchant and display the onboarding wizard. + context.disconnect = () => { + const common = wp.data.dispatch( CommonStoreName ); + + common.disconnectMerchant(); + + // eslint-disable-next-line no-console + console.log( 'Disconnected from PayPal. Reloading the page...' ); + + window.location.reload(); }; }; diff --git a/modules/ppcp-settings/resources/js/data/index.js b/modules/ppcp-settings/resources/js/data/index.js index 274aac790..393c09db2 100644 --- a/modules/ppcp-settings/resources/js/data/index.js +++ b/modules/ppcp-settings/resources/js/data/index.js @@ -1,16 +1,24 @@ import { addDebugTools } from './debug'; import * as Onboarding from './onboarding'; import * as Common from './common'; +import * as Styling from './styling'; +import * as Payment from './payment'; Onboarding.initStore(); Common.initStore(); +Payment.initStore(); +Styling.initStore(); export const OnboardingHooks = Onboarding.hooks; export const CommonHooks = Common.hooks; +export const PaymentHooks = Payment.hooks; +export const StylingHooks = Styling.hooks; export const OnboardingStoreName = Onboarding.STORE_NAME; export const CommonStoreName = Common.STORE_NAME; +export const PaymentStoreName = Payment.STORE_NAME; +export const StylingStoreName = Styling.STORE_NAME; -export * from './constants'; +export * from './configuration'; -addDebugTools( window.ppcpSettings, [ Onboarding, Common ] ); +addDebugTools( window.ppcpSettings, [ Onboarding, Common, Payment, Styling ] ); diff --git a/modules/ppcp-settings/resources/js/data/onboarding/actions.js b/modules/ppcp-settings/resources/js/data/onboarding/actions.js index dcf401995..8c6f2999f 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/actions.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/actions.js @@ -36,16 +36,55 @@ export const hydrate = ( payload ) => ( { payload, } ); +/** + * Generic transient-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setTransient = ( prop, value ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { [ prop ]: value }, +} ); + +/** + * Generic persistent-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setPersistent = ( prop, value ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { [ prop ]: value }, +} ); + /** * 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 }, -} ); +export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady ); + +/** + * Transient. Sets the "manualClientId" value. + * + * @param {string} manualClientId + * @return {Action} The action. + */ +export const setManualClientId = ( manualClientId ) => + setTransient( 'manualClientId', manualClientId ); + +/** + * Transient. Sets the "manualClientSecret" value. + * + * @param {string} manualClientSecret + * @return {Action} The action. + */ +export const setManualClientSecret = ( manualClientSecret ) => + setTransient( 'manualClientSecret', manualClientSecret ); /** * Persistent.Set the "onboarding completed" flag which shows or hides the wizard. @@ -53,10 +92,8 @@ export const setIsReady = ( isReady ) => ( { * @param {boolean} completed * @return {Action} The action. */ -export const setCompleted = ( completed ) => ( { - type: ACTION_TYPES.SET_PERSISTENT, - payload: { completed }, -} ); +export const setCompleted = ( completed ) => + setPersistent( 'completed', completed ); /** * Persistent. Sets the onboarding wizard to a new step. @@ -64,10 +101,7 @@ export const setCompleted = ( completed ) => ( { * @param {number} step * @return {Action} The action. */ -export const setStep = ( step ) => ( { - type: ACTION_TYPES.SET_PERSISTENT, - payload: { step }, -} ); +export const setStep = ( step ) => setPersistent( 'step', step ); /** * Persistent. Sets the "isCasualSeller" value. @@ -75,10 +109,8 @@ export const setStep = ( step ) => ( { * @param {boolean} isCasualSeller * @return {Action} The action. */ -export const setIsCasualSeller = ( isCasualSeller ) => ( { - type: ACTION_TYPES.SET_PERSISTENT, - payload: { isCasualSeller }, -} ); +export const setIsCasualSeller = ( isCasualSeller ) => + setPersistent( 'isCasualSeller', isCasualSeller ); /** * Persistent. Sets the "areOptionalPaymentMethodsEnabled" value. @@ -88,10 +120,11 @@ export const setIsCasualSeller = ( isCasualSeller ) => ( { */ export const setAreOptionalPaymentMethodsEnabled = ( areOptionalPaymentMethodsEnabled -) => ( { - type: ACTION_TYPES.SET_PERSISTENT, - payload: { areOptionalPaymentMethodsEnabled }, -} ); +) => + setPersistent( + 'areOptionalPaymentMethodsEnabled', + areOptionalPaymentMethodsEnabled + ); /** * Persistent. Sets the "products" array. @@ -99,10 +132,8 @@ export const setAreOptionalPaymentMethodsEnabled = ( * @param {string[]} products * @return {Action} The action. */ -export const setProducts = ( products ) => ( { - type: ACTION_TYPES.SET_PERSISTENT, - payload: { products }, -} ); +export const setProducts = ( products ) => + setPersistent( 'products', products ); /** * Side effect. Triggers the persistence of onboarding data to the server. diff --git a/modules/ppcp-settings/resources/js/data/constants.js b/modules/ppcp-settings/resources/js/data/onboarding/configuration.js similarity index 51% rename from modules/ppcp-settings/resources/js/data/constants.js rename to modules/ppcp-settings/resources/js/data/onboarding/configuration.js index 5654ad476..4b31689b5 100644 --- a/modules/ppcp-settings/resources/js/data/constants.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/configuration.js @@ -1,8 +1,24 @@ +/** + * Configuration for UI components. + * + * @file + */ + +/** + * Onboarding options for StepBusiness + * + * @type {Object} + */ export const BUSINESS_TYPES = { CASUAL_SELLER: 'casual_seller', BUSINESS: 'business', }; +/** + * Onboarding options for StepProducts + * + * @type {Object} + */ export const PRODUCT_TYPES = { VIRTUAL: 'virtual', PHYSICAL: 'physical', diff --git a/modules/ppcp-settings/resources/js/data/onboarding/constants.js b/modules/ppcp-settings/resources/js/data/onboarding/constants.js index 4b33c6701..396726199 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/constants.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/constants.js @@ -8,7 +8,7 @@ export const STORE_NAME = 'wc/paypal/onboarding'; /** - * REST path to hydrate data of this module by loading data from the WP DB.. + * REST path to hydrate data of this module by loading data from the WP DB. * * Used by: Resolvers * See: OnboardingRestEndpoint.php diff --git a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js index e8582821e..70999960c 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js @@ -9,30 +9,14 @@ import { useSelect, useDispatch } from '@wordpress/data'; -import { PRODUCT_TYPES } from '../constants'; +import { createHooksForStore } from '../utils'; +import { PRODUCT_TYPES } from './configuration'; 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, - setStep, - setCompleted, - setIsCasualSeller, - setAreOptionalPaymentMethodsEnabled, - setProducts, - } = useDispatch( STORE_NAME ); + const { useTransient, usePersistent } = createHooksForStore( STORE_NAME ); + + const { persist } = useDispatch( STORE_NAME ); // Read-only flags and derived state. const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] ); @@ -42,16 +26,22 @@ const useHooks = () => { ); // Transient accessors. - const isReady = useTransient( 'isReady' ); + const [ isReady ] = useTransient( 'isReady' ); + const [ manualClientId, setManualClientId ] = + useTransient( 'manualClientId' ); + const [ manualClientSecret, setManualClientSecret ] = + useTransient( 'manualClientSecret' ); // Persistent accessors. - const step = usePersistent( 'step' ); - const completed = usePersistent( 'completed' ); - const isCasualSeller = usePersistent( 'isCasualSeller' ); - const areOptionalPaymentMethodsEnabled = usePersistent( - 'areOptionalPaymentMethodsEnabled' - ); - const products = usePersistent( 'products' ); + const [ step, setStep ] = usePersistent( 'step' ); + const [ completed, setCompleted ] = usePersistent( 'completed' ); + const [ isCasualSeller, setIsCasualSeller ] = + usePersistent( 'isCasualSeller' ); + const [ + areOptionalPaymentMethodsEnabled, + setAreOptionalPaymentMethodsEnabled, + ] = usePersistent( 'areOptionalPaymentMethodsEnabled' ); + const [ products, setProducts ] = usePersistent( 'products' ); const savePersistent = async ( setter, value ) => { setter( value ); @@ -73,6 +63,14 @@ const useHooks = () => { setIsCasualSeller: ( value ) => { return savePersistent( setIsCasualSeller, value ); }, + manualClientId, + setManualClientId: ( value ) => { + return savePersistent( setManualClientId, value ); + }, + manualClientSecret, + setManualClientSecret: ( value ) => { + return savePersistent( setManualClientSecret, value ); + }, areOptionalPaymentMethodsEnabled, setAreOptionalPaymentMethodsEnabled: ( value ) => { return savePersistent( setAreOptionalPaymentMethodsEnabled, value ); @@ -88,6 +86,22 @@ const useHooks = () => { }; }; +export const useManualConnectionForm = () => { + const { + manualClientId, + setManualClientId, + manualClientSecret, + setManualClientSecret, + } = useHooks(); + + return { + manualClientId, + setManualClientId, + manualClientSecret, + setManualClientSecret, + }; +}; + export const useBusiness = () => { const { isCasualSeller, setIsCasualSeller } = useHooks(); diff --git a/modules/ppcp-settings/resources/js/data/onboarding/reducer.js b/modules/ppcp-settings/resources/js/data/onboarding/reducer.js index 2b16e2416..8d03f9fbf 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/reducer.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/reducer.js @@ -14,6 +14,8 @@ import ACTION_TYPES from './action-types'; const defaultTransient = Object.freeze( { isReady: false, + manualClientId: '', + manualClientSecret: '', // Read only values, provided by the server. flags: Object.freeze( { diff --git a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js index 2e0953437..9f3a7f35d 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js @@ -52,9 +52,6 @@ export const determineProducts = ( state ) => { * The store uses the Express-checkout product. */ derivedProducts.push( 'EXPRESS_CHECKOUT' ); - - // TODO: Add the "BCDC" product/feature - // Requirement: "EXPRESS_CHECKOUT with BCDC" } else { /** * Branch 3: Merchant is business, and can use CC payments. @@ -64,8 +61,7 @@ export const determineProducts = ( state ) => { } if ( canUseVaulting ) { - // TODO: Add the "Vaulting" product/feature - // Requirement: "... with Vault" + derivedProducts.push( 'ADVANCED_VAULTING' ); } return derivedProducts; diff --git a/modules/ppcp-settings/resources/js/data/payment/README.md b/modules/ppcp-settings/resources/js/data/payment/README.md new file mode 100644 index 000000000..b97f6ca4c --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/README.md @@ -0,0 +1,45 @@ +# Store template + +This template contains all files for a Redux store. + +## New Store: Redux integration + +1. Copy this folder, give it a correct name. +2. Check each file for `` placeholders and `TODO` remarks. +3. Edit the main store-index file and add the relevant store integration there. +4. Check the debug-module, and add relevant debug code. + - Register the store in the `reset()` method. + +--- + +Main store-index: +`modules/ppcp-settings/resources/js/data/index.js` + +Sample store integration: +```js +import * as YourStore from './yourStore'; +// ... +YourStore.initStore(); +// ... +export const YourStoreHooks = YourStore.hooks; +// ... +export const YourStoreName = YourStore.STORE_NAME; +// ... +addDebugTools( window.ppcpSettings, [ ..., YourStoreName ] ); +``` + +--- + +### New Store: PHP integration + +1. Create the **REST endpoint** for hydrating and persisting data. + - `modules/ppcp-settings/src/Endpoint/YourStoreRestEndpoint.php` + - Extend from base class `RestEndpoint` +2. Create the **data model** class to manage the DB interaction. + - `modules/ppcp-settings/src/Data/YourStoreSettings.php` + - Extend from base class `AbstractDataModel` +3. Create relevant **DI services** for both files. + - `modules/ppcp-settings/services.php` +4. Register the REST endpoint in the **service module**. + - `modules/ppcp-settings/src/SettingsModule.php` + - Find the action `rest_api_init` diff --git a/modules/ppcp-settings/resources/js/data/payment/action-types.js b/modules/ppcp-settings/resources/js/data/payment/action-types.js new file mode 100644 index 000000000..e68253c5d --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/action-types.js @@ -0,0 +1,18 @@ +/** + * Action Types: Define unique identifiers for actions across all store modules. + * + * @file + */ + +export default { + // Transient data. + SET_TRANSIENT: 'PAYMENT:SET_TRANSIENT', + + // Persistent data. + SET_PERSISTENT: 'PAYMENT:SET_PERSISTENT', + RESET: 'PAYMENT:RESET', + HYDRATE: 'PAYMENT:HYDRATE', + + // Controls - always start with "DO_". + DO_PERSIST_DATA: 'PAYMENT:DO_PERSIST_DATA', +}; diff --git a/modules/ppcp-settings/resources/js/data/payment/actions.js b/modules/ppcp-settings/resources/js/data/payment/actions.js new file mode 100644 index 000000000..95c0235b1 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/actions.js @@ -0,0 +1,71 @@ +/** + * 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. + */ + +/** + * Special. Resets all values in the store to initial defaults. + * + * @return {Action} The action. + */ +export const reset = () => ( { type: ACTION_TYPES.RESET } ); + +/** + * Persistent. Set the full store details during app initialization. + * + * @param {{data: {}, flags?: {}}} payload + * @return {Action} The action. + */ +export const hydrate = ( payload ) => ( { + type: ACTION_TYPES.HYDRATE, + payload, +} ); + +/** + * Transient. Marks the store as "ready", i.e., fully initialized. + * + * @param {boolean} isReady + * @return {Action} The action. + */ +export const setIsReady = ( isReady ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { isReady }, +} ); + +/** + * Side effect. Triggers the persistence of store 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 }; +}; + +/** + * Generic persistent-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setPersistent = ( prop, value ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { [ prop ]: value }, +} ); diff --git a/modules/ppcp-settings/resources/js/data/payment/constants.js b/modules/ppcp-settings/resources/js/data/payment/constants.js new file mode 100644 index 000000000..82c428074 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/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/payment'; + +/** + * REST path to hydrate data of this module by loading data from the WP DB. + * + * Used by: Resolvers + * See: payment.php + * + * @type {string} + */ +export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/payment'; + +/** + * REST path to persist data of this module to the WP DB. + * + * Used by: Controls + * See: payment.php + * + * @type {string} + */ +export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/payment'; diff --git a/modules/ppcp-settings/resources/js/data/payment/controls.js b/modules/ppcp-settings/resources/js/data/payment/controls.js new file mode 100644 index 000000000..9295b62bc --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/controls.js @@ -0,0 +1,23 @@ +/** + * 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 } ) { + return await apiFetch( { + path: REST_PERSIST_PATH, + method: 'POST', + data, + } ); + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/payment/hooks.js b/modules/ppcp-settings/resources/js/data/payment/hooks.js new file mode 100644 index 000000000..c10f0969e --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/hooks.js @@ -0,0 +1,155 @@ +/** + * Hooks: Provide the main API for components to interact with the store. + * + * These encapsulate store interactions, offering a consistent interface. + * Hooks simplify data access and manipulation for components. + * + * @file + */ + +import { useSelect, useDispatch } from '@wordpress/data'; + +import { 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, setPersistent } = useDispatch( STORE_NAME ); + + // Read-only flags and derived state. + // Nothing here yet. + + // Transient accessors. + const isReady = useTransient( 'isReady' ); + + // PayPal checkout. + const paypal = usePersistent( 'ppcp-gateway' ); + const venmo = usePersistent( 'venmo' ); + const payLater = usePersistent( 'pay-later' ); + const creditCard = usePersistent( 'ppcp-card-button-gateway' ); + + // Online card Payments. + const advancedCreditCard = usePersistent( 'ppcp-credit-card-gateway' ); + const fastlane = usePersistent( 'ppcp-axo-gateway' ); + const applePay = usePersistent( 'ppcp-applepay' ); + const googlePay = usePersistent( 'ppcp-googlepay' ); + + // Alternative payment methods. + const bancontact = usePersistent( 'ppcp-bancontact' ); + const blik = usePersistent( 'ppcp-blik' ); + const eps = usePersistent( 'ppcp-eps' ); + const ideal = usePersistent( 'ppcp-ideal' ); + const mybank = usePersistent( 'ppcp-mybank' ); + const p24 = usePersistent( 'ppcp-p24' ); + const trustly = usePersistent( 'ppcp-trustly' ); + const multibanco = usePersistent( 'ppcp-multibanco' ); + const pui = usePersistent( 'ppcp-pay-upon-invoice-gateway' ); + const oxxo = usePersistent( 'ppcp-oxxo-gateway' ); + + return { + persist, + isReady, + setPersistent, + paypal, + venmo, + payLater, + creditCard, + advancedCreditCard, + fastlane, + applePay, + googlePay, + bancontact, + blik, + eps, + ideal, + mybank, + p24, + trustly, + multibanco, + pui, + oxxo, + }; +}; + +export const useState = () => { + const { persist, isReady } = useHooks(); + return { persist, isReady }; +}; + +export const usePaymentMethods = () => { + const { setPersistent } = useHooks(); + + return { + setPersistent, + }; +}; + +export const usePaymentMethodsPayPalCheckout = () => { + const { paypal, venmo, payLater, creditCard } = useHooks(); + const paymentMethodsPayPalCheckout = [ + paypal, + venmo, + payLater, + creditCard, + ]; + + return { + paymentMethodsPayPalCheckout, + }; +}; + +export const usePaymentMethodsOnlineCardPayments = () => { + const { advancedCreditCard, fastlane, applePay, googlePay } = useHooks(); + const paymentMethodsOnlineCardPayments = [ + advancedCreditCard, + fastlane, + applePay, + googlePay, + ]; + + return { + paymentMethodsOnlineCardPayments, + }; +}; + +export const usePaymentMethodsAlternative = () => { + const { + bancontact, + blik, + eps, + ideal, + mybank, + p24, + trustly, + multibanco, + pui, + oxxo, + } = useHooks(); + + const paymentMethodsAlternative = [ + bancontact, + blik, + eps, + ideal, + mybank, + p24, + trustly, + multibanco, + pui, + oxxo, + ]; + + return { + paymentMethodsAlternative, + }; +}; diff --git a/modules/ppcp-settings/resources/js/data/payment/index.js b/modules/ppcp-settings/resources/js/data/payment/index.js new file mode 100644 index 000000000..28c162f98 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/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/payment/reducer.js b/modules/ppcp-settings/resources/js/data/payment/reducer.js new file mode 100644 index 000000000..4894c9996 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/reducer.js @@ -0,0 +1,72 @@ +/** + * 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. + +// Transient: Values that are _not_ saved to the DB (like app lifecycle-flags). +const defaultTransient = Object.freeze( { + isReady: false, +} ); + +// Persistent: Values that are loaded from the DB. +const defaultPersistent = Object.freeze( { + 'ppcp-gateway': {}, + venmo: {}, + 'pay-later': {}, + 'ppcp-card-button-gateway': {}, + 'ppcp-credit-card-gateway': {}, + 'ppcp-axo-gateway': {}, + 'ppcp-applepay': {}, + 'ppcp-googlepay': {}, + 'ppcp-bancontact': {}, + 'ppcp-blik': {}, + 'ppcp-eps': {}, + 'ppcp-ideal': {}, + 'ppcp-mybank': {}, + 'ppcp-p24': {}, + 'ppcp-trustly': {}, + 'ppcp-multibanco': {}, + 'ppcp-pay-upon-invoice-gateway': {}, + 'ppcp-oxxo-gateway': {}, +} ); + +// Reducer logic. + +const [ setTransient, setPersistent ] = createSetters( + defaultTransient, + defaultPersistent +); + +const reducer = 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 ) => { + const cleanState = setTransient( + setPersistent( state, defaultPersistent ), + defaultTransient + ); + + // Keep "read-only" details and initialization flags. + cleanState.isReady = true; + + return cleanState; + }, + + [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => + setPersistent( state, payload.data ), +} ); + +export default reducer; diff --git a/modules/ppcp-settings/resources/js/data/payment/resolvers.js b/modules/ppcp-settings/resources/js/data/payment/resolvers.js new file mode 100644 index 000000000..ebc6832bb --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/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 payment details.', + 'woocommerce-paypal-payments' + ) + ); + } + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/payment/selectors.js b/modules/ppcp-settings/resources/js/data/payment/selectors.js new file mode 100644 index 000000000..14334fcf3 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/payment/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/settings-tab/action-types.js b/modules/ppcp-settings/resources/js/data/settings-tab/action-types.js new file mode 100644 index 000000000..d13c0cbcf --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/action-types.js @@ -0,0 +1,40 @@ +/** + * Settings action types + * + * Defines the constants used for dispatching actions in the settings store. + * Each constant represents a unique action type that can be handled by reducers. + * + * @file + */ + +export default { + /** + * Represents setting transient (temporary) state data. + * These values are not persisted and will reset on page reload. + */ + SET_TRANSIENT: 'ppcp/settings/SET_TRANSIENT', + + /** + * Represents setting persistent state data. + * These values are meant to be saved to the server and persist between page loads. + */ + SET_PERSISTENT: 'ppcp/settings/SET_PERSISTENT', + + /** + * Resets the store state to its initial values. + * Used when needing to clear all settings data. + */ + RESET: 'ppcp/settings/RESET', + + /** + * Initializes the store with data, typically used during store initialization + * to set up the initial state with data from the server. + */ + HYDRATE: 'ppcp/settings/HYDRATE', + + /** + * Triggers the persistence of store data to the server. + * Used when changes need to be saved to the backend. + */ + DO_PERSIST_DATA: 'ppcp/settings/DO_PERSIST_DATA', +}; diff --git a/modules/ppcp-settings/resources/js/data/settings-tab/actions.js b/modules/ppcp-settings/resources/js/data/settings-tab/actions.js new file mode 100644 index 000000000..a13cedeab --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/actions.js @@ -0,0 +1,71 @@ +/** + * 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. + */ + +/** + * Special. Resets all values in the store to initial defaults. + * + * @return {Action} The action. + */ +export const reset = () => ( { + type: ACTION_TYPES.RESET, +} ); + +/** + * Persistent. Sets the full store details during app initialization. + * + * @param {Object} payload Initial store data + * @return {Action} The action. + */ +export const hydrate = ( payload ) => ( { + type: ACTION_TYPES.HYDRATE, + payload, +} ); + +/** + * Transient. Marks the store as "ready", i.e., fully initialized. + * + * @param {boolean} isReady Whether the store is ready + * @return {Action} The action. + */ +export const setIsReady = ( isReady ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { isReady }, +} ); + +/** + * Persistent. Updates the settings data in the store. + * + * @param {Object} settings The settings object to store + * @return {Action} The action. + */ +export const setSettings = ( settings ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: settings, +} ); + +/** + * Side effect. Triggers the persistence of store data to the server. + * Yields an action with the current persistent data to be saved. + * + * @return {Action} The action. + */ +export const persist = function* () { + const data = yield select( STORE_NAME ).persistentData(); + yield { type: ACTION_TYPES.DO_PERSIST_DATA, data }; +}; diff --git a/modules/ppcp-settings/resources/js/data/settings-tab/constants.js b/modules/ppcp-settings/resources/js/data/settings-tab/constants.js new file mode 100644 index 000000000..dd7faa120 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/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/settings'; + +/** + * REST path to hydrate data of this module by loading data from the WP DB. + * + * Used by: Resolvers + * See: SettingsRestEndpoint.php + * + * @type {string} + */ +export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/settings'; + +/** + * REST path to persist data of this module to the WP DB. + * + * Used by: Controls + * See: SettingsRestEndpoint.php + * + * @type {string} + */ +export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/settings'; diff --git a/modules/ppcp-settings/resources/js/data/settings-tab/controls.js b/modules/ppcp-settings/resources/js/data/settings-tab/controls.js new file mode 100644 index 000000000..11e5e6a04 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/controls.js @@ -0,0 +1,34 @@ +/** + * 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'; + +/** + * Control handlers for settings store actions. + * Each handler maps to an ACTION_TYPE and performs the corresponding async operation. + */ +export const controls = { + /** + * Persists settings data to the server via REST API. + * Triggered by the DO_PERSIST_DATA action to save settings changes. + * + * @param {Object} action The action object + * @param {Object} action.data The settings data to persist + * @return {Promise} The API response + */ + async [ ACTION_TYPES.DO_PERSIST_DATA ]( { data } ) { + return await apiFetch( { + path: REST_PERSIST_PATH, + method: 'POST', + data, + } ); + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/settings-tab/hooks.js b/modules/ppcp-settings/resources/js/data/settings-tab/hooks.js new file mode 100644 index 000000000..89041b207 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/hooks.js @@ -0,0 +1,55 @@ +/** + * Hooks: Provide the main API for components to interact with the store. + * + * These encapsulate store interactions, offering a consistent interface. + * Hooks simplify data access and manipulation for components. + * + * @file + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { 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, setSettings } = useDispatch( STORE_NAME ); + + // Read-only flags and derived state. + const isReady = useTransient( 'isReady' ); + + // Persistent accessors. + const settings = useSelect( + ( select ) => select( STORE_NAME ).persistentData(), + [] + ); + + return { + persist, + isReady, + settings, + setSettings, + }; +}; + +export const useSettingsState = () => { + const { persist, isReady } = useHooks(); + return { persist, isReady }; +}; + +export const useSettings = () => { + const { settings, setSettings } = useHooks(); + return { + settings, + setSettings, + }; +}; diff --git a/modules/ppcp-settings/resources/js/data/settings-tab/index.js b/modules/ppcp-settings/resources/js/data/settings-tab/index.js new file mode 100644 index 000000000..affca562e --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/index.js @@ -0,0 +1,51 @@ +/** + * Store Configuration: Defines and registers the settings data store. + * + * Creates a Redux-style store with WordPress data layer integration. + * Combines reducers, actions, selectors and controls into a unified store. + * + * @file + */ + +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'; + +/** + * Initializes and registers the settings store with WordPress data layer. + * Combines custom controls with WordPress data controls. + * + * @return {boolean} True if initialization succeeded, false otherwise. + */ +export const initStore = () => { + try { + const store = createReduxStore( STORE_NAME, { + reducer, + controls: { ...wpControls, ...controls }, + actions, + selectors, + resolvers, + } ); + register( store ); + + // Verify store registration + const isStoreRegistered = Boolean( wp.data.select( STORE_NAME ) ); + if ( ! isStoreRegistered ) { + console.error( 'Store registration verification failed' ); + return false; + } + + return true; + } catch ( error ) { + console.error( 'Failed to initialize settings store:', error ); + return false; + } +}; + +export { hooks, selectors, STORE_NAME }; diff --git a/modules/ppcp-settings/resources/js/data/settings-tab/reducer.js b/modules/ppcp-settings/resources/js/data/settings-tab/reducer.js new file mode 100644 index 000000000..a374f60af --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/reducer.js @@ -0,0 +1,107 @@ +/** + * 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. + +/** + * Transient: Values that are _not_ saved to the DB (like app lifecycle-flags). + * These reset on page reload. + */ +const defaultTransient = Object.freeze( { + isReady: false, +} ); + +/** + * Persistent: Values that are loaded from and saved to the DB. + * These represent the core PayPal payment settings configuration. + */ +const defaultPersistent = Object.freeze( { + invoicePrefix: '', // Prefix for PayPal invoice IDs + authorizeOnly: false, // Whether to only authorize payments initially + captureVirtualOnlyOrders: false, // Auto-capture virtual-only orders + savePaypalAndVenmo: false, // Enable PayPal & Venmo vaulting + saveCreditCardAndDebitCard: false, // Enable card vaulting + payNowExperience: false, // Enable Pay Now experience + sandboxAccountCredentials: false, // Use sandbox credentials + sandboxMode: null, // Sandbox mode configuration + sandboxEnabled: false, // Whether sandbox mode is active + sandboxClientId: '', // Sandbox API client ID + sandboxSecretKey: '', // Sandbox API secret key + sandboxConnected: false, // Sandbox connection status + logging: false, // Enable debug logging + subtotalMismatchFallback: null, // Handling for subtotal mismatches + brandName: '', // Merchant brand name for PayPal + softDescriptor: '', // Payment descriptor on statements + paypalLandingPage: null, // PayPal checkout landing page + buttonLanguage: '', // Language for PayPal buttons +} ); + +// Reducer logic. + +const [ setTransient, setPersistent ] = createSetters( + defaultTransient, + defaultPersistent +); + +/** + * Reducer implementation mapping actions to state updates. + */ +const reducer = createReducer( defaultTransient, defaultPersistent, { + /** + * Updates temporary state values + * + * @param {Object} state Current state + * @param {Object} payload Update payload + * @return {Object} Updated state + */ + [ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) => { + return setTransient( state, payload ); + }, + + /** + * Updates persistent configuration values + * + * @param {Object} state Current state + * @param {Object} payload Update payload + * @return {Object} Updated state + */ + [ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) => + setPersistent( state, payload ), + + /** + * Resets state to defaults while maintaining initialization status + * + * @param {Object} state Current state + * @return {Object} Reset state + */ + [ ACTION_TYPES.RESET ]: ( state ) => { + const cleanState = setTransient( + setPersistent( state, defaultPersistent ), + defaultTransient + ); + cleanState.isReady = true; // Keep initialization flag + return cleanState; + }, + + /** + * Initializes persistent state with data from the server + * + * @param {Object} state Current state + * @param {Object} payload Hydration payload containing server data + * @param {Object} payload.data The settings data to hydrate + * @return {Object} Hydrated state + */ + [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => + setPersistent( state, payload.data ), +} ); + +export default reducer; diff --git a/modules/ppcp-settings/resources/js/data/settings-tab/resolvers.js b/modules/ppcp-settings/resources/js/data/settings-tab/resolvers.js new file mode 100644 index 000000000..da28dd66d --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/resolvers.js @@ -0,0 +1,57 @@ +/** + * 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 PayPal settings from the site's REST API. + * Hydrates the store with the retrieved data and marks it as ready. + * + * @generator + * @yield {Object} API fetch and dispatch actions + */ + *persistentData() { + try { + // Fetch settings data from REST API + const result = yield apiFetch( { + path: REST_HYDRATE_PATH, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + } ); + + // Update store with retrieved data + yield dispatch( STORE_NAME ).hydrate( result ); + // Mark store as ready for use + yield dispatch( STORE_NAME ).setIsReady( true ); + } catch ( e ) { + // Log detailed error information for debugging + console.error( 'Full error details:', { + error: e, + path: REST_HYDRATE_PATH, + store: STORE_NAME, + } ); + + // Display user-friendly error notice + yield dispatch( 'core/notices' ).createErrorNotice( + __( + 'Error retrieving PayPal settings details.', + 'woocommerce-paypal-payments' + ) + ); + } + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/settings-tab/selectors.js b/modules/ppcp-settings/resources/js/data/settings-tab/selectors.js new file mode 100644 index 000000000..c35f04306 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings-tab/selectors.js @@ -0,0 +1,46 @@ +/** + * 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 + */ + +/** + * Empty frozen object used as fallback when state is undefined. + * + * @constant + * @type {Object} + */ +const EMPTY_OBJ = Object.freeze( {} ); + +/** + * Base selector that ensures a valid state object. + * + * @param {Object|undefined} state The current state + * @return {Object} The state or empty object if undefined + */ +export const getState = ( state ) => state || EMPTY_OBJ; + +/** + * Retrieves persistent (saved) data from the store. + * + * @param {Object} state The current state + * @return {Object} The persistent data or empty object if undefined + */ +export const persistentData = ( state ) => { + return getState( state ).data || EMPTY_OBJ; +}; + +/** + * Retrieves transient (temporary) data from the store. + * Excludes persistent data stored in the 'data' property. + * + * @param {Object} state The current state + * @return {Object} The transient state or empty object if undefined + */ +export const transientData = ( state ) => { + const { data, ...transientState } = getState( state ); + return transientState || EMPTY_OBJ; +}; diff --git a/modules/ppcp-settings/resources/js/data/settings/connection-details-data.js b/modules/ppcp-settings/resources/js/data/settings/connection-details-data.js new file mode 100644 index 000000000..65eeb62c2 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings/connection-details-data.js @@ -0,0 +1,178 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { InputSettingsBlock } from '../../Components/ReusableComponents/SettingsBlocks'; +import { Button } from '@wordpress/components'; + +/** + * Generates options for the environment mode settings. + * + * @param {Object} config - Configuration for the mode. + * @param {Object} settings - Current settings. + * @param {Function} updateFormValue - Callback to update settings. + * @return {Array} Options array. + */ +const generateOptions = ( config, settings, updateFormValue ) => [ + { + id: `${ config.mode }_mode`, + value: `${ config.mode }_mode`, + label: config.labelTitle, + description: config.labelDescription, + additionalContent: ( + + ), + }, + { + id: 'manual_connect', + value: 'manual_connect', + label: __( 'Manual Connect', 'woocommerce-paypal-payments' ), + description: sprintf( + __( + 'For advanced users: Connect a custom PayPal REST app for full control over your integration. For more information on creating a PayPal REST application, click here.', + 'woocommerce-paypal-payments' + ), + '#' + ), + additionalContent: ( + <> + + + + + ), + }, +]; + +/** + * Generates data for a given mode (sandbox or production). + * + * @param {Object} config - Configuration for the mode. + * @param {Object} settings - Current settings. + * @param {Function} updateFormValue - Callback to update settings. + * @return {Object} Mode configuration. + */ +const generateModeData = ( config, settings, updateFormValue ) => ( { + title: config.title, + description: config.description, + connectTitle: __( + `Connect ${ config.label } Account`, + 'woocommerce-paypal-payments' + ), + connectDescription: config.connectDescription, + options: generateOptions( config, settings, updateFormValue ), +} ); + +export const sandboxData = ( { settings = {}, updateFormValue = () => {} } ) => + generateModeData( + { + mode: 'sandbox', + label: 'Sandbox', + title: __( 'Sandbox', 'woocommerce-paypal-payments' ), + description: __( + "Test your site in PayPal's Sandbox environment.", + 'woocommerce-paypal-payments' + ), + connectDescription: __( + 'Connect a PayPal Sandbox account in order to test your website. Transactions made will not result in actual money movement. Do not fulfil orders completed in Sandbox mode.', + 'woocommerce-paypal-payments' + ), + labelTitle: __( 'Sandbox Mode', 'woocommerce-paypal-payments' ), + labelDescription: __( + 'Activate Sandbox mode to safely test PayPal with sample data. Once your store is ready to go live, you can easily switch to your production account.', + 'woocommerce-paypal-payments' + ), + buttonText: __( + 'Connect Sandbox Account', + 'woocommerce-paypal-payments' + ), + clientIdTitle: __( + 'Sandbox Client ID', + 'woocommerce-paypal-payments' + ), + secretKeyTitle: __( + 'Sandbox Secret Key', + 'woocommerce-paypal-payments' + ), + }, + settings, + updateFormValue + ); + +export const productionData = ( { + settings = {}, + updateFormValue = () => {}, +} ) => + generateModeData( + { + mode: 'production', + label: 'Live', + title: __( 'Live Payments', 'woocommerce-paypal-payments' ), + description: __( + 'Your site is currently configured in Sandbox mode to test payments. When you are ready, launch your site and receive live payments via PayPal.', + 'woocommerce-paypal-payments' + ), + connectDescription: __( + 'Connect a live PayPal account to launch your site and receive live payments via PayPal. PayPal will guide you through the setup process.', + 'woocommerce-paypal-payments' + ), + labelTitle: __( 'Production Mode', 'woocommerce-paypal-payments' ), + labelDescription: __( + 'Activate Production mode to connect your live account and receive live payments via PayPal. Stay connected in Sandbox mode to continue testing payments before going live.', + 'woocommerce-paypal-payments' + ), + buttonText: __( + 'Set up and connect live PayPal Account', + 'woocommerce-paypal-payments' + ), + clientIdTitle: __( + 'Live Account Client ID', + 'woocommerce-paypal-payments' + ), + secretKeyTitle: __( + 'Live Account Secret Key', + 'woocommerce-paypal-payments' + ), + }, + settings, + updateFormValue + ); diff --git a/modules/ppcp-settings/resources/js/data/settings/tab-overview-todos-data.js b/modules/ppcp-settings/resources/js/data/settings/tab-overview-todos-data.js new file mode 100644 index 000000000..818f7180f --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings/tab-overview-todos-data.js @@ -0,0 +1,170 @@ +import { __ } from '@wordpress/i18n'; +import { selectTab, TAB_IDS } from '../../utils/tabSelector'; + +export const todosData = [ + { + id: 'enable_fastlane', + title: __( 'Enable Fastlane', 'woocommerce-paypal-payments' ), + description: __( + 'Accelerate your guest checkout with Fastlane by PayPal.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.PAYMENT_METHODS, 'ppcp-card-payments-card' ); + }, + }, + { + id: 'enable_credit_debit_cards', + title: __( + 'Enable Credit and Debit Cards on your checkout', + 'woocommerce-paypal-payments' + ), + description: __( + 'Credit and Debit Cards is now available for Blocks checkout pages.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.PAYMENT_METHODS, 'ppcp-card-payments-card' ); + }, + }, + { + id: 'enable_pay_later_messaging', + title: __( + 'Enable Pay Later messaging', + 'woocommerce-paypal-payments' + ), + description: __( + 'Show Pay Later messaging to boost conversion rate and increase cart size.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.OVERVIEW, 'pay_later_messaging' ); + }, + }, + { + id: 'configure_paypal_subscription', + title: __( + 'Configure a PayPal Subscription', + 'woocommerce-paypal-payments' + ), + description: __( + 'Connect a subscriptions-type product from WooCommerce with PayPal.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + console.log( + 'Take merchant to product list, filtered with subscription-type products' + ); + }, + }, + { + id: 'register_domain_apple_pay', + title: __( + 'Register Domain for Apple Pay', + 'woocommerce-paypal-payments' + ), + description: __( + 'To enable Apple Pay, you must register your domain with PayPal.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.OVERVIEW, 'apple_pay' ); + }, + }, + { + id: 'add_digital_wallets_to_account', + title: __( + 'Add digital wallets to your account', + 'woocommerce-paypal-payments' + ), + description: __( + 'Add the ability to accept Apple Pay & Google Pay to your PayPal account.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + console.log( + 'Take merchant to PayPal to enable Apple Pay & Google Pay' + ); + }, + }, + { + id: 'add_apple_pay_to_account', + title: __( + 'Add Apple Pay to your account', + 'woocommerce-paypal-payments' + ), + description: __( + 'Add the ability to accept Apple Pay to your PayPal account.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + console.log( 'Take merchant to PayPal to enable Apple Pay' ); + }, + }, + { + id: 'add_google_pay_to_account', + title: __( + 'Add Google Pay to your account', + 'woocommerce-paypal-payments' + ), + description: __( + 'Add the ability to accept Google Pay to your PayPal account.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + console.log( 'Take merchant to PayPal to enable Google Pay' ); + }, + }, + { + id: 'enable_apple_pay', + title: __( 'Enable Apple Pay', 'woocommerce-paypal-payments' ), + description: __( + 'Allow your buyers to check out via Apple Pay.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.OVERVIEW, 'apple_pay' ); + }, + }, + { + id: 'enable_google_pay', + title: __( 'Enable Google Pay', 'woocommerce-paypal-payments' ), + description: __( + 'Allow your buyers to check out via Google Pay.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.OVERVIEW, 'google_pay' ); + }, + }, +]; diff --git a/modules/ppcp-settings/resources/js/data/settings/tab-styling-data.js b/modules/ppcp-settings/resources/js/data/settings/tab-styling-data.js deleted file mode 100644 index 6bdb4f643..000000000 --- a/modules/ppcp-settings/resources/js/data/settings/tab-styling-data.js +++ /dev/null @@ -1,162 +0,0 @@ -import { __ } from '@wordpress/i18n'; - -const cartAndExpressCheckoutSettings = { - paymentMethods: [], - style: { - shape: 'pill', - label: 'paypal', - color: 'gold', - }, -}; - -const settings = { - paymentMethods: [], - style: { - layout: 'vertical', - shape: cartAndExpressCheckoutSettings.style.shape, - label: cartAndExpressCheckoutSettings.style.label, - color: cartAndExpressCheckoutSettings.style.color, - tagline: false, - }, -}; - -export const defaultLocationSettings = { - cart: { - value: 'cart', - label: __( 'Cart', 'woocommerce-paypal-payments' ), - settings: { ...cartAndExpressCheckoutSettings }, - // translators: %s: Link to Cart page - description: __( - 'Customize the appearance of the PayPal smart buttons on the [MISSING LINK]Cart page and select which additional payment buttons to display in this location.', - 'wooocommerce-paypal-payments' - ), - descriptionLink: '#', - }, - 'classic-checkout': { - value: 'classic-checkout', - label: __( 'Classic Checkout', 'woocommerce-paypal-payments' ), - settings: { ...settings }, - // translators: %s: Link to Classic Checkout page - description: __( - 'Customize the appearance of the PayPal smart buttons on the [MISSING LINK]Classic Checkout page and choose which additional payment buttons to display in this location.', - 'wooocommerce-paypal-payments' - ), - descriptionLink: '#', - }, - 'express-checkout': { - value: 'express-checkout', - label: __( 'Express Checkout', 'woocommerce-paypal-payments' ), - settings: { ...cartAndExpressCheckoutSettings }, - // translators: %s: Link to Express Checkout location - description: __( - 'Customize the appearance of the PayPal smart buttons on the [MISSING LINK]Express Checkout location and choose which additional payment buttons to display in this location.', - 'wooocommerce-paypal-payments' - ), - descriptionLink: '#', - }, - 'mini-cart': { - value: 'mini-cart', - label: __( 'Mini Cart', 'woocommerce-paypel-payements' ), - settings: { ...settings }, - // translators: %s: Link to Mini Cart - description: __( - 'Customize the appearance of the PayPal smart buttons on the [MISSING LINK]Mini Cart and choose which additional payment buttons to display in this location.', - 'wooocommerce-paypal-payments' - ), - descriptionLink: '#', - }, - 'product-page': { - value: 'product-page', - label: __( 'Product Page', 'woocommerce-paypal-payments' ), - settings: { ...settings }, - // translators: %s: Link to Product Page - description: __( - 'Customize the appearance of the PayPal smart buttons on the [MISSING LINK]Product Page and choose which additional payment buttons to display in this location.', - 'wooocommerce-paypal-payments' - ), - descriptionLink: '#', - }, -}; - -export const paymentMethodOptions = [ - { - value: 'venmo', - label: __( 'Venmo', 'woocommerce-paypal-payments' ), - }, - { - value: 'paylater', - label: __( 'Pay Later', 'woocommerce-paypal-payments' ), - }, - { - value: 'googlepay', - label: __( 'Google Pay', 'woocommerce-paypal-payments' ), - }, - { - value: 'applepay', - label: __( 'Apple Pay', 'woocommerce-paypal-payments' ), - }, -]; - -export const buttonLabelOptions = [ - { - value: 'paypal', - label: __( 'PayPal', 'woocommerce-paypal-payments' ), - }, - { - value: 'checkout', - label: __( 'Checkout', 'woocommerce-paypal-payments' ), - }, - { - value: 'buynow', - label: __( 'PayPal Buy Now', 'woocommerce-paypal-payments' ), - }, - { - value: 'pay', - label: __( 'Pay with PayPal', 'woocommerce-paypal-payments' ), - }, -]; - -export const colorOptions = [ - { - value: 'gold', - label: __( 'Gold (Recommended)', 'woocommerce-paypal-payments' ), - }, - { - value: 'blue', - label: __( 'Blue', 'woocommerce-paypal-payments' ), - }, - { - value: 'silver', - label: __( 'Silver', 'woocommerce-paypal-payments' ), - }, - { - value: 'black', - label: __( 'Black', 'woocommerce-paypal-payments' ), - }, - { - value: 'white', - label: __( 'White', 'woocommerce-paypal-payments' ), - }, -]; - -export const buttonLayoutOptions = [ - { - label: __( 'Vertical', 'woocommerce-paypal-payments' ), - value: 'vertical', - }, - { - label: __( 'Horizontal', 'woocommerce-paypal-payments' ), - value: 'horizontal', - }, -]; - -export const shapeOptions = [ - { - value: 'pill', - label: __( 'Pill', 'woocommerce-paypal-payments' ), - }, - { - value: 'rect', - label: __( 'Rectangle', 'woocommerce-paypal-payments' ), - }, -]; diff --git a/modules/ppcp-settings/resources/js/data/styling/action-types.js b/modules/ppcp-settings/resources/js/data/styling/action-types.js new file mode 100644 index 000000000..d487b1f5f --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/action-types.js @@ -0,0 +1,18 @@ +/** + * Action Types: Define unique identifiers for actions across all store modules. + * + * @file + */ + +export default { + // Transient data. + SET_TRANSIENT: 'STYLE:SET_TRANSIENT', + + // Persistent data. + SET_PERSISTENT: 'STYLE:SET_PERSISTENT', + RESET: 'STYLE:RESET', + HYDRATE: 'STYLE:HYDRATE', + + // Controls - always start with "DO_". + DO_PERSIST_DATA: 'STYLE:DO_PERSIST_DATA', +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/actions.js b/modules/ppcp-settings/resources/js/data/styling/actions.js new file mode 100644 index 000000000..f7bdb4339 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/actions.js @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * Special. Resets all values in the store to initial defaults. + * + * @return {Action} The action. + */ +export const reset = () => ( { type: ACTION_TYPES.RESET } ); + +/** + * Persistent. Set the full store details during app initialization. + * + * @param {{data: {}, flags?: {}}} payload + * @return {Action} The action. + */ +export const hydrate = ( payload ) => ( { + type: ACTION_TYPES.HYDRATE, + payload, +} ); + +/** + * Generic transient-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setTransient = ( prop, value ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { [ prop ]: value }, +} ); + +/** + * Generic persistent-data updater. + * + * @param {string} prop Name of the property to update. + * @param {any} value The new value of the property. + * @return {Action} The action. + */ +export const setPersistent = ( prop, value ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { [ prop ]: value }, +} ); + +/** + * Transient. Changes the "ready-state" of the module. + * + * @param {boolean} state Whether the store is ready to be used. + * @return {Action} The action. + */ +export const setIsReady = ( state ) => setTransient( 'isReady', state ); + +/** + * Side effect. Triggers the persistence of store 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 }; +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/configuration.js b/modules/ppcp-settings/resources/js/data/styling/configuration.js new file mode 100644 index 000000000..130d5451b --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/configuration.js @@ -0,0 +1,131 @@ +/** + * Configuration for UI components. + * + * @file + */ + +import { __ } from '@wordpress/i18n'; + +export const STYLING_LOCATIONS = { + cart: { + value: 'cart', + label: __( 'Cart', 'woocommerce-paypal-payments' ), + link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-cart', + props: { layout: false, tagline: false }, + }, + classicCheckout: { + value: 'classicCheckout', + label: __( 'Classic Checkout', 'woocommerce-paypal-payments' ), + link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-checkout', + props: { layout: true, tagline: true }, + }, + expressCheckout: { + value: 'expressCheckout', + label: __( 'Express Checkout', 'woocommerce-paypal-payments' ), + link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-block-express-checkout', + props: { layout: false, tagline: false }, + }, + miniCart: { + value: 'miniCart', + label: __( 'Mini Cart', 'woocommerce-paypel-payements' ), + link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-mini-cart', + props: { layout: true, tagline: true }, + }, + product: { + value: 'product', + label: __( 'Product Page', 'woocommerce-paypal-payments' ), + link: 'https://woocommerce.com/document/woocommerce-paypal-payments/#button-on-single-product', + props: { layout: true, tagline: true }, + }, +}; + +export const STYLING_LABELS = { + paypal: { + value: 'paypal', + label: __( 'PayPal', 'woocommerce-paypal-payments' ), + }, + checkout: { + value: 'checkout', + label: __( 'Checkout', 'woocommerce-paypal-payments' ), + }, + buynow: { + value: 'buynow', + label: __( 'PayPal Buy Now', 'woocommerce-paypal-payments' ), + }, + pay: { + value: 'pay', + label: __( 'Pay with PayPal', 'woocommerce-paypal-payments' ), + }, +}; + +export const STYLING_COLORS = { + gold: { + value: 'gold', + label: __( 'Gold (Recommended)', 'woocommerce-paypal-payments' ), + }, + blue: { + value: 'blue', + label: __( 'Blue', 'woocommerce-paypal-payments' ), + }, + silver: { + value: 'silver', + label: __( 'Silver', 'woocommerce-paypal-payments' ), + }, + black: { + value: 'black', + label: __( 'Black', 'woocommerce-paypal-payments' ), + }, + white: { + value: 'white', + label: __( 'White', 'woocommerce-paypal-payments' ), + }, +}; + +export const STYLING_LAYOUTS = { + vertical: { + value: 'vertical', + label: __( 'Vertical', 'woocommerce-paypal-payments' ), + }, + horizontal: { + value: 'horizontal', + label: __( 'Horizontal', 'woocommerce-paypal-payments' ), + }, +}; + +export const STYLING_SHAPES = { + rect: { + value: 'rect', + label: __( 'Rectangle', 'woocommerce-paypal-payments' ), + }, + pill: { + value: 'pill', + label: __( 'Pill', 'woocommerce-paypal-payments' ), + }, +}; + +export const STYLING_PAYMENT_METHODS = { + paypal: { + value: '', + label: __( 'PayPal', 'woocommerce-paypal-payments' ), + checked: true, + disabled: true, + }, + venmo: { + value: 'venmo', + label: __( 'Venmo', 'woocommerce-paypal-payments' ), + isFunding: true, + }, + paylater: { + value: 'paylater', + label: __( 'Pay Later', 'woocommerce-paypal-payments' ), + isFunding: true, + }, + googlepay: { + value: 'googlepay', + label: __( 'Google Pay', 'woocommerce-paypal-payments' ), + }, + applepay: { + value: 'applepay', + label: __( 'Apple Pay', 'woocommerce-paypal-payments' ), + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/constants.js b/modules/ppcp-settings/resources/js/data/styling/constants.js new file mode 100644 index 000000000..db1082f33 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/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/style'; + +/** + * REST path to hydrate data of this module by loading data from the WP DB. + * + * Used by: Resolvers + * See: StylingRestEndpoint.php + * + * @type {string} + */ +export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/styling'; + +/** + * REST path to persist data of this module to the WP DB. + * + * Used by: Controls + * See: StylingRestEndpoint.php + * + * @type {string} + */ +export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/styling'; diff --git a/modules/ppcp-settings/resources/js/data/styling/controls.js b/modules/ppcp-settings/resources/js/data/styling/controls.js new file mode 100644 index 000000000..9295b62bc --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/controls.js @@ -0,0 +1,23 @@ +/** + * 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 } ) { + return await apiFetch( { + path: REST_PERSIST_PATH, + method: 'POST', + data, + } ); + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/hooks.js b/modules/ppcp-settings/resources/js/data/styling/hooks.js new file mode 100644 index 000000000..8227d0124 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/hooks.js @@ -0,0 +1,211 @@ +/** + * 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 { useCallback } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; + +import { createHooksForStore } from '../utils'; +import { STORE_NAME } from './constants'; +import { + STYLING_COLORS, + STYLING_LABELS, + STYLING_LAYOUTS, + STYLING_LOCATIONS, + STYLING_PAYMENT_METHODS, + STYLING_SHAPES, +} from './configuration'; + +const useHooks = () => { + const { useTransient } = createHooksForStore( STORE_NAME ); + const { persist, setPersistent } = useDispatch( STORE_NAME ); + + // Transient accessors. + const [ isReady ] = useTransient( 'isReady' ); + const [ location, setLocation ] = useTransient( 'location' ); + + // Persistent accessors. + const persistentData = useSelect( + ( select ) => select( STORE_NAME ).persistentData(), + [] + ); + + const getLocationProp = useCallback( + ( locationId, prop ) => { + if ( undefined === persistentData[ locationId ]?.[ prop ] ) { + console.error( + `Trying to access non-existent style property: ${ locationId }.${ prop }. Possibly wrong style name - review the reducer.` + ); + return null; + } + return persistentData[ locationId ][ prop ]; + }, + [ persistentData ] + ); + + const setLocationProp = useCallback( + ( locationId, prop, value ) => { + const updatedStyles = { + ...persistentData[ locationId ], + [ prop ]: value, + }; + + setPersistent( locationId, updatedStyles ); + }, + [ persistentData, setPersistent ] + ); + + return { + persist, + isReady, + location, + setLocation, + getLocationProp, + setLocationProp, + }; +}; + +export const useStore = () => { + const { persist, isReady } = useHooks(); + return { persist, isReady }; +}; + +export const useStylingLocation = () => { + const { location, setLocation } = useHooks(); + return { location, setLocation }; +}; + +export const useLocationProps = ( location ) => { + const { getLocationProp, setLocationProp } = useHooks(); + const details = STYLING_LOCATIONS[ location ] ?? {}; + + const sanitize = ( value ) => ( undefined === value ? true : !! value ); + + return { + choices: Object.values( STYLING_LOCATIONS ), + details, + isActive: sanitize( getLocationProp( location, 'enabled' ) ), + setActive: ( state ) => + setLocationProp( location, 'enabled', sanitize( state ) ), + }; +}; + +export const usePaymentMethodProps = ( location ) => { + const { getLocationProp, setLocationProp } = useHooks(); + + const sanitize = ( value ) => { + if ( Array.isArray( value ) ) { + return value; + } + return value ? [ value ] : []; + }; + + return { + choices: Object.values( STYLING_PAYMENT_METHODS ), + paymentMethods: sanitize( getLocationProp( location, 'methods' ) ), + setPaymentMethods: ( methods ) => + setLocationProp( location, 'methods', sanitize( methods ) ), + }; +}; + +export const useColorProps = ( location ) => { + const { getLocationProp, setLocationProp } = useHooks(); + + const sanitize = ( value ) => { + const isValidColor = Object.values( STYLING_COLORS ).some( + ( color ) => color.value === value + ); + return isValidColor ? value : STYLING_COLORS.gold.value; + }; + + return { + choices: Object.values( STYLING_COLORS ), + color: sanitize( getLocationProp( location, 'color' ) ), + setColor: ( color ) => + setLocationProp( location, 'color', sanitize( color ) ), + }; +}; + +export const useShapeProps = ( location ) => { + const { getLocationProp, setLocationProp } = useHooks(); + + const sanitize = ( value ) => { + const isValidColor = Object.values( STYLING_SHAPES ).some( + ( color ) => color.value === value + ); + return isValidColor ? value : STYLING_SHAPES.rect.value; + }; + + return { + choices: Object.values( STYLING_SHAPES ), + shape: sanitize( getLocationProp( location, 'shape' ) ), + setShape: ( shape ) => + setLocationProp( location, 'shape', sanitize( shape ) ), + }; +}; + +export const useLabelProps = ( location ) => { + const { getLocationProp, setLocationProp } = useHooks(); + + const sanitize = ( value ) => { + const isValidColor = Object.values( STYLING_LABELS ).some( + ( color ) => color.value === value + ); + return isValidColor ? value : STYLING_LABELS.paypal.value; + }; + + return { + choices: Object.values( STYLING_LABELS ), + label: sanitize( getLocationProp( location, 'label' ) ), + setLabel: ( label ) => + setLocationProp( location, 'label', sanitize( label ) ), + }; +}; + +export const useLayoutProps = ( location ) => { + const { getLocationProp, setLocationProp } = useHooks(); + const { details } = useLocationProps( location ); + const isAvailable = false !== details.props.layout; + + const sanitize = ( value ) => { + const isValidColor = Object.values( STYLING_LAYOUTS ).some( + ( color ) => color.value === value + ); + return isValidColor ? value : STYLING_LAYOUTS.vertical.value; + }; + + return { + choices: Object.values( STYLING_LAYOUTS ), + isAvailable, + layout: sanitize( getLocationProp( location, 'layout' ) ), + setLayout: ( layout ) => + setLocationProp( location, 'layout', sanitize( layout ) ), + }; +}; + +export const useTaglineProps = ( location ) => { + const { getLocationProp, setLocationProp } = useHooks(); + const { details } = useLocationProps( location ); + + // Tagline is only available for horizontal layouts. + const isAvailable = + false !== details.props.tagline && + STYLING_LAYOUTS.horizontal.value === + getLocationProp( location, 'layout' ); + + const sanitize = ( value ) => !! value; + + return { + isAvailable, + tagline: isAvailable + ? sanitize( getLocationProp( location, 'tagline' ) ) + : false, + setTagline: ( tagline ) => + setLocationProp( location, 'tagline', sanitize( tagline ) ), + }; +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/index.js b/modules/ppcp-settings/resources/js/data/styling/index.js new file mode 100644 index 000000000..28c162f98 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/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/styling/reducer.js b/modules/ppcp-settings/resources/js/data/styling/reducer.js new file mode 100644 index 000000000..db92d646b --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/reducer.js @@ -0,0 +1,128 @@ +/** + * Reducer: Defines store structure and state updates for this module. + * + * Manages both transient (temporary) and persistent (saved) state. + * The initial state must define all properties, as dynamic additions are not supported. + * + * @file + */ + +import { createReducer, createSetters } from '../utils'; +import ACTION_TYPES from './action-types'; +import { + STYLING_COLORS, + STYLING_LABELS, + STYLING_LAYOUTS, + STYLING_LOCATIONS, + STYLING_SHAPES, +} from './configuration'; + +// Store structure. + +// Transient: Values that are _not_ saved to the DB (like app lifecycle-flags). +const defaultTransient = Object.freeze( { + isReady: false, + location: STYLING_LOCATIONS.cart.value, // Which location is selected in the Styling tab. +} ); + +// Persistent: Values that are loaded from the DB. +const defaultPersistent = Object.freeze( { + [ STYLING_LOCATIONS.cart.value ]: Object.freeze( { + enabled: true, + methods: [], + label: STYLING_LABELS.pay.value, + shape: STYLING_SHAPES.rect.value, + color: STYLING_COLORS.gold.value, + } ), + [ STYLING_LOCATIONS.classicCheckout.value ]: Object.freeze( { + enabled: true, + methods: [], + label: STYLING_LABELS.checkout.value, + shape: STYLING_SHAPES.rect.value, + color: STYLING_COLORS.gold.value, + layout: STYLING_LAYOUTS.vertical.value, + tagline: false, + } ), + [ STYLING_LOCATIONS.expressCheckout.value ]: Object.freeze( { + enabled: true, + methods: [], + label: STYLING_LABELS.checkout.value, + shape: STYLING_SHAPES.rect.value, + color: STYLING_COLORS.gold.value, + } ), + [ STYLING_LOCATIONS.miniCart.value ]: Object.freeze( { + enabled: true, + methods: [], + label: STYLING_LABELS.pay.value, + shape: STYLING_SHAPES.rect.value, + color: STYLING_COLORS.gold.value, + layout: STYLING_LAYOUTS.vertical.value, + tagline: false, + } ), + [ STYLING_LOCATIONS.product.value ]: Object.freeze( { + enabled: true, + methods: [], + label: STYLING_LABELS.buynow.value, + shape: STYLING_SHAPES.rect.value, + color: STYLING_COLORS.gold.value, + layout: STYLING_LAYOUTS.vertical.value, + tagline: false, + } ), +} ); + +const sanitizeLocation = ( oldDetails, newDetails ) => { + // Skip if provided details are not a plain object. + if ( + ! newDetails || + 'object' !== typeof newDetails || + Array.isArray( newDetails ) + ) { + return oldDetails; + } + + return { ...oldDetails, ...newDetails }; +}; + +// Reducer logic. + +const [ setTransient, setPersistent ] = createSetters( + defaultTransient, + defaultPersistent +); + +const reducer = 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 ) => { + const cleanState = setTransient( + setPersistent( state, defaultPersistent ), + defaultTransient + ); + + // Keep "read-only" details and initialization flags. + cleanState.isReady = true; + + return cleanState; + }, + + [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => { + const validData = Object.keys( defaultPersistent ).reduce( + ( data, location ) => { + data[ location ] = sanitizeLocation( + state.data[ location ], + payload.data[ location ] + ); + return data; + }, + {} + ); + + return setPersistent( state, validData ); + }, +} ); + +export default reducer; diff --git a/modules/ppcp-settings/resources/js/data/styling/resolvers.js b/modules/ppcp-settings/resources/js/data/styling/resolvers.js new file mode 100644 index 000000000..e59794746 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/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 style-details.', + 'woocommerce-paypal-payments' + ) + ); + } + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/selectors.js b/modules/ppcp-settings/resources/js/data/styling/selectors.js new file mode 100644 index 000000000..14334fcf3 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/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/utils.js b/modules/ppcp-settings/resources/js/data/utils.js index 45c652862..7d3a14af7 100644 --- a/modules/ppcp-settings/resources/js/data/utils.js +++ b/modules/ppcp-settings/resources/js/data/utils.js @@ -1,3 +1,6 @@ +import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; + /** * Updates an object with new values, filtering based on allowed keys. * @@ -13,6 +16,10 @@ const updateObject = ( oldObject, newValues, allowedKeys = {} ) => ( { ...Object.keys( newValues ).reduce( ( acc, key ) => { if ( key in allowedKeys ) { acc[ key ] = newValues[ key ]; + } else { + console.warn( + `Ignoring unknown key "${ key }" - to use it, add it to the initial store properties in the reducer.` + ); } return acc; }, {} ), @@ -73,3 +80,63 @@ export const createReducer = ( return state; }; }; + +/** + * Returns an object with two hooks: + * - useTransient( prop ) + * - usePersistent( prop ) + * + * Both hooks have a similar syntax to the native "useState( prop )" hook, but provide access to + * a transient or persistent property in the relevant Redux store. + * + * Sample: + * + * const { useTransient } = createHooksForStore( STORE_NAME ); + * const [ isReady, setIsReady ] = useTransient( 'isReady' ); + * + * @param {string} storeName Store name. + * @return {{useTransient, usePersistent}} Store hooks. + */ +export const createHooksForStore = ( storeName ) => { + const createHook = ( selector, dispatcher ) => ( key ) => { + const value = useSelect( + ( select ) => { + const store = select( storeName ); + if ( ! store?.[ selector ] ) { + throw new Error( + `Please create the selector "${ selector }" for store "${ storeName }"` + ); + } + const selectorResult = store[ selector ](); + if ( undefined === selectorResult?.[ key ] ) { + console.error( + `Warning: ${ selector }()[${ key }] is undefined in store "${ storeName }". This may indicate a bug.` + ); + } + return selectorResult?.[ key ]; + }, + [ key ] + ); + + const actions = useDispatch( storeName ); + + const setValue = useCallback( + ( newValue ) => { + if ( ! actions?.[ dispatcher ] ) { + throw new Error( + `Please create the action "${ dispatcher }" for store "${ storeName }"` + ); + } + actions[ dispatcher ]( key, newValue ); + }, + [ actions, key ] + ); + + return [ value, setValue ]; + }; + + return { + useTransient: createHook( 'transientData', 'setTransient' ), + usePersistent: createHook( 'persistentData', 'setPersistent' ), + }; +}; diff --git a/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js b/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js index d34e74f42..f6837c488 100644 --- a/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js +++ b/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js @@ -1,9 +1,12 @@ import { __ } from '@wordpress/i18n'; import { useDispatch } from '@wordpress/data'; +import { useState, useEffect, useCallback, useRef } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; import { CommonHooks, OnboardingHooks } from '../data'; -import { openPopup } from '../utils/window'; + +const PAYPAL_PARTNER_SDK_URL = + 'https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js'; const MESSAGES = { CONNECTED: __( 'Connected to PayPal', 'woocommerce-paypal-payments' ), @@ -32,35 +35,137 @@ const MESSAGES = { const ACTIVITIES = { CONNECT_SANDBOX: 'ISU_LOGIN_SANDBOX', CONNECT_PRODUCTION: 'ISU_LOGIN_PRODUCTION', + CONNECT_ISU: 'ISU_LOGIN', CONNECT_MANUAL: 'MANUAL_LOGIN', }; -const handlePopupWithCompletion = ( url, onError ) => { - return new Promise( ( resolve ) => { - const popup = openPopup( url ); +export const useHandleOnboardingButton = ( isSandbox ) => { + const { sandboxOnboardingUrl } = CommonHooks.useSandbox(); + const { productionOnboardingUrl } = CommonHooks.useProduction(); + const products = OnboardingHooks.useDetermineProducts(); + const { withActivity } = CommonHooks.useBusyState(); + const { authenticateWithOAuth } = CommonHooks.useAuthentication(); + const [ onboardingUrl, setOnboardingUrl ] = useState( '' ); + const [ scriptLoaded, setScriptLoaded ] = useState( false ); + const timerRef = useRef( null ); - if ( ! popup ) { - onError( MESSAGES.POPUP_BLOCKED ); - resolve( false ); + useEffect( () => { + const fetchOnboardingUrl = async () => { + let res; + if ( isSandbox ) { + res = await sandboxOnboardingUrl(); + } else { + res = await productionOnboardingUrl( products ); + } + + if ( res.success && res.data ) { + setOnboardingUrl( res.data ); + } else { + console.error( 'Failed to fetch onboarding URL' ); + } + }; + + fetchOnboardingUrl(); + }, [ isSandbox, productionOnboardingUrl, products, sandboxOnboardingUrl ] ); + + useEffect( () => { + /** + * The partner.js script initializes all onboarding buttons in the onload event. + * When no buttons are present, a JS error is displayed; i.e. we should load this script + * only when the button is ready (with a valid href and data-attributes). + */ + if ( ! onboardingUrl ) { return; } - // Check popup state every 500ms - const checkPopup = setInterval( () => { - if ( popup.closed ) { - clearInterval( checkPopup ); - resolve( true ); - } - }, 500 ); + const script = document.createElement( 'script' ); + script.id = 'partner-js'; + script.src = PAYPAL_PARTNER_SDK_URL; + script.onload = () => { + setScriptLoaded( true ); + }; + document.body.appendChild( script ); return () => { - clearInterval( checkPopup ); + /** + * When the component is unmounted, remove the partner.js script, as well as the + * dynamic scripts it loaded (signup-js and rampConfig-js) + * + * This is important, as the onboarding button is only initialized during the onload + * event of those scripts; i.e. we need to load the scripts again, when the button is + * rendered again. + */ + const onboardingScripts = [ + 'partner-js', + 'signup-js', + 'rampConfig-js', + ]; - if ( popup && ! popup.closed ) { - popup.close(); - } + onboardingScripts.forEach( ( id ) => { + const el = document.querySelector( `script[id="${ id }"]` ); + + if ( el?.parentNode ) { + el.parentNode.removeChild( el ); + } + } ); }; - } ); + }, [ onboardingUrl ] ); + + const setCompleteHandler = useCallback( + ( environment ) => { + const onComplete = async ( authCode, sharedId ) => { + /** + * Until now, the full page is blocked by PayPal's semi-transparent, black overlay. + * But at this point, the overlay is removed, while we process the sharedId and + * authCode via a REST call. + * + * Note: The REST response is irrelevant, since PayPal will most likely refresh this + * frame before the REST endpoint returns a value. Using "withActivity" is more of a + * visual cue to the user that something is still processing in the background. + */ + await withActivity( + ACTIVITIES.CONNECT_ISU, + 'Validating the connection details', + async () => { + await authenticateWithOAuth( + sharedId, + authCode, + 'sandbox' === environment + ); + } + ); + }; + + const addHandler = () => { + const MiniBrowser = window.PAYPAL?.apps?.Signup?.MiniBrowser; + if ( ! MiniBrowser || MiniBrowser.onOnboardComplete ) { + return; + } + + MiniBrowser.onOnboardComplete = onComplete; + }; + + // Ensure the onComplete handler is not removed by a PayPal init script. + timerRef.current = setInterval( addHandler, 250 ); + }, + [ authenticateWithOAuth, withActivity ] + ); + + const removeCompleteHandler = useCallback( () => { + if ( timerRef.current ) { + clearInterval( timerRef.current ); + timerRef.current = null; + } + + delete window.PAYPAL?.apps?.Signup?.MiniBrowser?.onOnboardComplete; + }, [] ); + + return { + onboardingUrl, + scriptLoaded, + setCompleteHandler, + removeCompleteHandler, + }; }; const useConnectionBase = () => { @@ -92,104 +197,55 @@ const useConnectionBase = () => { }; }; -const useConnectionAttempt = ( connectFn, errorMessage ) => { - const { handleFailed, createErrorNotice, handleCompleted } = - useConnectionBase(); - - return async ( ...args ) => { - const res = await connectFn( ...args ); - - if ( ! res.success || ! res.data ) { - handleFailed( res, errorMessage ); - return false; - } - - const popupClosed = await handlePopupWithCompletion( - res.data, - createErrorNotice - ); - - if ( popupClosed ) { - await handleCompleted(); - } - - return popupClosed; - }; -}; - export const useSandboxConnection = () => { - const { connectToSandbox, isSandboxMode, setSandboxMode } = - CommonHooks.useSandbox(); - const { withActivity } = CommonHooks.useBusyState(); - const connectionAttempt = useConnectionAttempt( - connectToSandbox, - MESSAGES.SANDBOX_ERROR - ); - - const handleSandboxConnect = async () => { - return withActivity( - ACTIVITIES.CONNECT_SANDBOX, - 'Connecting to sandbox account', - connectionAttempt - ); - }; + const { isSandboxMode, setSandboxMode } = CommonHooks.useSandbox(); return { - handleSandboxConnect, isSandboxMode, setSandboxMode, }; }; -export const useProductionConnection = () => { - const { connectToProduction } = CommonHooks.useProduction(); - const { withActivity } = CommonHooks.useBusyState(); - const products = OnboardingHooks.useDetermineProducts(); - const connectionAttempt = useConnectionAttempt( - () => connectToProduction( products ), - MESSAGES.PRODUCTION_ERROR - ); - - const handleProductionConnect = async () => { - return withActivity( - ACTIVITIES.CONNECT_PRODUCTION, - 'Connecting to production account', - connectionAttempt - ); - }; - - return { handleProductionConnect }; -}; - -export const useManualConnection = () => { +export const useDirectAuthentication = () => { const { handleFailed, handleCompleted, createErrorNotice } = useConnectionBase(); const { withActivity } = CommonHooks.useBusyState(); const { - connectViaIdAndSecret, + authenticateWithCredentials, isManualConnectionMode, setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - } = CommonHooks.useManualConnection(); + } = CommonHooks.useAuthentication(); - const handleConnectViaIdAndSecret = async ( { validation } = {} ) => { + const handleDirectAuthentication = async ( connectionDetails ) => { return withActivity( ACTIVITIES.CONNECT_MANUAL, 'Connecting manually via Client ID and Secret', async () => { - if ( 'function' === typeof validation ) { + let data; + + if ( 'function' === typeof connectionDetails ) { try { - validation(); + data = connectionDetails(); } catch ( exception ) { createErrorNotice( exception.message ); return; } + } else if ( 'object' === typeof connectionDetails ) { + data = connectionDetails; } - const res = await connectViaIdAndSecret(); + if ( ! data || ! data.clientId || ! data.clientSecret ) { + createErrorNotice( + 'Invalid connection details (clientID or clientSecret missing)' + ); + return; + } + + const res = await authenticateWithCredentials( + data.clientId, + data.clientSecret, + !! data.isSandbox + ); if ( res.success ) { await handleCompleted(); @@ -203,12 +259,8 @@ export const useManualConnection = () => { }; return { - handleConnectViaIdAndSecret, + handleDirectAuthentication, isManualConnectionMode, setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, }; }; diff --git a/modules/ppcp-settings/resources/js/hooks/useNavigation.js b/modules/ppcp-settings/resources/js/hooks/useNavigation.js new file mode 100644 index 000000000..f477a0b91 --- /dev/null +++ b/modules/ppcp-settings/resources/js/hooks/useNavigation.js @@ -0,0 +1,10 @@ +/** + * Navigate to the WooCommerce "Payments" settings tab, i.e. exit the settings app. + */ +const goToWooCommercePaymentsTab = () => { + window.location.href = window.ppcpSettings.wcPaymentsTabUrl; +}; + +export const useNavigation = () => { + return { goToWooCommercePaymentsTab }; +}; diff --git a/modules/ppcp-settings/resources/js/index.js b/modules/ppcp-settings/resources/js/index.js index 6dfd70ed5..f044642ba 100644 --- a/modules/ppcp-settings/resources/js/index.js +++ b/modules/ppcp-settings/resources/js/index.js @@ -1,6 +1,6 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import App from './Components/App'; createRoot( document.getElementById( 'ppcp-settings-container' ) ).render( diff --git a/modules/ppcp-settings/resources/js/utils/countryPriceInfo.js b/modules/ppcp-settings/resources/js/utils/countryPriceInfo.js index 17504453b..c5cf52a3e 100644 --- a/modules/ppcp-settings/resources/js/utils/countryPriceInfo.js +++ b/modules/ppcp-settings/resources/js/utils/countryPriceInfo.js @@ -2,81 +2,139 @@ export const countryPriceInfo = { US: { fixedFee: { USD: 0.49, + GBP: 0.39, + CAD: 0.59, + AUD: 0.59, + EUR: 0.39, }, checkout: 3.49, - ccf: 2.59, - dw: 2.59, - apm: 2.59, - fastlane: 2.59, + plater: 4.99, + ccf: { + percentage: 2.59, + fixedFee: 0.29, + }, + dw: { + percentage: 2.59, + fixedFee: 0.29, + }, + apm: { + percentage: 2.89, + fixedFee: 0.29, + }, + fast: { + percentage: 2.59, + fixedFee: 0.29, + }, standardCardFields: 2.99, }, UK: { fixedFee: { GPB: 0.3, + USD: 0.3, + CAD: 0.3, + AUD: 0.3, + EUR: 0.35, }, checkout: 2.9, + plater: 2.9, ccf: 1.2, dw: 1.2, + fast: 1.2, apm: 1.2, standardCardFields: 1.2, }, CA: { fixedFee: { CAD: 0.3, + USD: 0.3, + GBP: 0.2, + AUD: 0.3, + EUR: 0.35, }, checkout: 2.9, ccf: 2.7, dw: 2.7, + fast: 2.7, apm: 2.9, standardCardFields: 2.9, }, AU: { fixedFee: { AUD: 0.3, + USD: 0.3, + GBP: 0.2, + CAD: 0.3, + EUR: 0.35, }, checkout: 2.6, + plater: 2.6, ccf: 1.75, dw: 1.75, + fast: 1.75, apm: 2.6, standardCardFields: 2.6, }, FR: { fixedFee: { EUR: 0.35, + USD: 0.3, + GBP: 0.3, + CAD: 0.3, + AUD: 0.3, }, checkout: 2.9, + plater: 2.9, ccf: 1.2, dw: 1.2, + fast: 1.2, apm: 1.2, standardCardFields: 1.2, }, IT: { fixedFee: { - EUR: 0.35, + EUR: 0.35, + USD: 0.3, + GBP: 0.3, + CAD: 0.3, + AUD: 0.3, }, checkout: 3.4, + plater: 3.4, ccf: 1.2, dw: 1.2, + fast: 1.2, apm: 1.2, standardCardFields: 1.2, }, DE: { fixedFee: { EUR: 0.39, + USD: 0.49, + GBP: 0.29, + CAD: 0.59, + AUD: 0.59, }, checkout: 2.99, + plater: 2.99, ccf: 2.99, dw: 2.99, + fast: 2.99, apm: 2.99, standardCardFields: 2.99, }, ES: { fixedFee: { - EUR: 0.35, + EUR: 0.35, + USD: 0.3, + GBP: 0.3, + CAD: 0.3, + AUD: 0.3, }, checkout: 2.9, + plater: 2.9, ccf: 1.2, dw: 1.2, + fast: 1.2, apm: 1.2, standardCardFields: 1.2, }, diff --git a/modules/ppcp-settings/resources/js/utils/tabSelector.js b/modules/ppcp-settings/resources/js/utils/tabSelector.js new file mode 100644 index 000000000..5c7105ffc --- /dev/null +++ b/modules/ppcp-settings/resources/js/utils/tabSelector.js @@ -0,0 +1,60 @@ +// Tab panel IDs +export const TAB_IDS = { + OVERVIEW: 'tab-panel-0-overview', + PAYMENT_METHODS: 'tab-panel-0-payment-methods', + SETTINGS: 'tab-panel-0-settings', + STYLING: 'tab-panel-0-styling', + PAY_LATER_MESSAGING: 'tab-panel-0-pay-later-messaging', +}; + +/** + * Select a tab by simulating a click event and scroll to specified element, + * accounting for navigation container height + * + * TODO: Once the TabPanel gets migrated to Tabs (TabPanel v2) we need to remove this in favor of programmatic tab switching: https://github.com/WordPress/gutenberg/issues/52997 + * + * @param {string} tabId - The ID of the tab to select + * @param {string} [scrollToId] - Optional ID of the element to scroll to + * @return {Promise} - Resolves when tab switch and scroll are complete + */ +export const selectTab = ( tabId, scrollToId ) => { + return new Promise( ( resolve ) => { + const tab = document.getElementById( tabId ); + if ( tab ) { + tab.click(); + setTimeout( () => { + const scrollTarget = scrollToId + ? document.getElementById( scrollToId ) + : document.getElementById( 'ppcp-settings-container' ); + + if ( scrollTarget ) { + const navContainer = document.querySelector( + '.ppcp-r-navigation-container' + ); + const navHeight = navContainer + ? navContainer.offsetHeight + : 0; + + // Get the current scroll position and element's position relative to viewport + const rect = scrollTarget.getBoundingClientRect(); + + // Calculate the final position with offset + const scrollPosition = + rect.top + window.scrollY - ( navHeight + 55 ); + + window.scrollTo( { + top: scrollPosition, + behavior: 'smooth', + } ); + + // Resolve after scroll animation + setTimeout( resolve, 300 ); + } else { + resolve(); + } + }, 100 ); + } else { + resolve(); + } + } ); +}; diff --git a/modules/ppcp-settings/resources/js/utils/window.js b/modules/ppcp-settings/resources/js/utils/window.js deleted file mode 100644 index 165874302..000000000 --- a/modules/ppcp-settings/resources/js/utils/window.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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 89afdfc47..c311364ec 100644 --- a/modules/ppcp-settings/services.php +++ b/modules/ppcp-settings/services.php @@ -10,19 +10,27 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; -use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings; +use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings; use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile; +use WooCommerce\PayPalCommerce\Settings\Data\PaymentSettings; +use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel; +use WooCommerce\PayPalCommerce\Settings\Endpoint\AuthenticationRestEndpoint; 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\PaymentRestEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\RefreshFeatureStatusEndpoint; -use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; +use WooCommerce\PayPalCommerce\Settings\Endpoint\WebhookSettingsEndpoint; +use WooCommerce\PayPalCommerce\Settings\Endpoint\SettingsRestEndpoint; +use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener; +use WooCommerce\PayPalCommerce\Settings\Service\AuthenticationManager; use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator; use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; -use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener; +use WooCommerce\PayPalCommerce\Settings\Endpoint\StylingRestEndpoint; +use WooCommerce\PayPalCommerce\Settings\Data\StylingSettings; +use WooCommerce\PayPalCommerce\Settings\Service\DataSanitizer; return array( 'settings.url' => static function ( ContainerInterface $container ) : string { @@ -57,19 +65,31 @@ return array( ); }, 'settings.data.general' => static function ( ContainerInterface $container ) : GeneralSettings { - return new GeneralSettings(); - }, - 'settings.data.common' => static function ( ContainerInterface $container ) : CommonSettings { - return new CommonSettings( + return new GeneralSettings( $container->get( 'api.shop.country' ), $container->get( 'api.shop.currency.getter' )->get(), + $container->get( 'wcgateway.is-send-only-country' ) + ); + }, + 'settings.data.styling' => static function ( ContainerInterface $container ) : StylingSettings { + return new StylingSettings( + $container->get( 'settings.service.sanitizer' ) ); }, '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' ) ); + return new CommonRestEndpoint( $container->get( 'settings.data.general' ) ); + }, + 'settings.rest.payment' => static function ( ContainerInterface $container ) : PaymentRestEndpoint { + return new PaymentRestEndpoint(); + }, + 'settings.rest.styling' => static function ( ContainerInterface $container ) : StylingRestEndpoint { + return new StylingRestEndpoint( + $container->get( 'settings.data.styling' ), + $container->get( 'settings.service.sanitizer' ) + ); }, 'settings.rest.refresh_feature_status' => static function ( ContainerInterface $container ) : RefreshFeatureStatusEndpoint { return new RefreshFeatureStatusEndpoint( @@ -78,17 +98,21 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, - '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( 'settings.data.general' ) + 'settings.rest.connect_manual' => static function ( ContainerInterface $container ) : AuthenticationRestEndpoint { + return new AuthenticationRestEndpoint( + $container->get( 'settings.service.authentication_manager' ), ); }, 'settings.rest.login_link' => static function ( ContainerInterface $container ) : LoginLinkRestEndpoint { return new LoginLinkRestEndpoint( - $container->get( 'settings.service.connection-url-generators' ), + $container->get( 'settings.service.connection-url-generator' ), + ); + }, + 'settings.rest.webhooks' => static function ( ContainerInterface $container ) : WebhookSettingsEndpoint { + return new WebhookSettingsEndpoint( + $container->get( 'api.endpoint.webhook' ), + $container->get( 'webhook.registrar' ), + $container->get( 'webhook.status.simulation' ) ); }, 'settings.casual-selling.supported-countries' => static function ( ContainerInterface $container ) : array { @@ -152,8 +176,9 @@ return array( return new ConnectionListener( $page_id, - $container->get( 'settings.data.common' ), $container->get( 'settings.service.onboarding-url-manager' ), + $container->get( 'settings.service.authentication_manager' ), + $container->get( 'http.redirector' ), $container->get( 'woocommerce.logger.woocommerce' ) ); }, @@ -166,33 +191,27 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, - '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' ), - ), + 'settings.service.connection-url-generator' => static function ( ContainerInterface $container ) : ConnectionUrlGenerator { + return new ConnectionUrlGenerator( + $container->get( 'api.env.endpoint.partner-referrals' ), + $container->get( 'api.repository.partner-referrals-data' ), + $container->get( 'settings.service.onboarding-url-manager' ), + $container->get( 'woocommerce.logger.woocommerce' ) ); - - $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' ), - $environment, - $container->get( 'settings.service.onboarding-url-manager' ), - $container->get( 'woocommerce.logger.woocommerce' ) - ); - } - - return $generators; }, - 'settings.switch-ui.endpoint' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint { + 'settings.service.authentication_manager' => static function ( ContainerInterface $container ) : AuthenticationManager { + return new AuthenticationManager( + $container->get( 'settings.data.general' ), + $container->get( 'api.env.paypal-host' ), + $container->get( 'api.env.endpoint.login-seller' ), + $container->get( 'api.repository.partner-referrals-data' ), + $container->get( 'woocommerce.logger.woocommerce' ), + ); + }, + 'settings.service.sanitizer' => static function ( ContainerInterface $container ) : DataSanitizer { + return new DataSanitizer(); + }, + 'settings.ajax.switch_ui' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint { return new SwitchSettingsUiEndpoint( $container->get( 'woocommerce.logger.woocommerce' ), $container->get( 'button.request-data' ), @@ -200,4 +219,13 @@ return array( $container->get( 'api.merchant_id' ) !== '' ); }, + 'settings.rest.settings' => static function( ContainerInterface $container ): SettingsRestEndpoint { + return new SettingsRestEndpoint( + $container->get( 'settings.data.settings' ), + $container->get( 'woocommerce.logger.woocommerce' ), + ); + }, + 'settings.data.settings' => static function() : SettingsModel { + return new SettingsModel(); + }, ); diff --git a/modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php b/modules/ppcp-settings/src/Ajax/SwitchSettingsUiEndpoint.php similarity index 96% rename from modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php rename to modules/ppcp-settings/src/Ajax/SwitchSettingsUiEndpoint.php index 03057297f..464262d41 100644 --- a/modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php +++ b/modules/ppcp-settings/src/Ajax/SwitchSettingsUiEndpoint.php @@ -1,13 +1,13 @@ location = $location; + $this->enabled = $enabled; + $this->methods = $methods; + $this->shape = $shape; + $this->label = $label; + $this->color = $color; + $this->layout = $layout; + $this->tagline = $tagline; + } +} diff --git a/modules/ppcp-settings/src/DTO/MerchantConnectionDTO.php b/modules/ppcp-settings/src/DTO/MerchantConnectionDTO.php new file mode 100644 index 000000000..f79a85d76 --- /dev/null +++ b/modules/ppcp-settings/src/DTO/MerchantConnectionDTO.php @@ -0,0 +1,75 @@ +is_sandbox = $is_sandbox; + $this->client_id = $client_id; + $this->client_secret = $client_secret; + $this->merchant_id = $merchant_id; + $this->merchant_email = $merchant_email; + } +} diff --git a/modules/ppcp-settings/src/Data/AbstractDataModel.php b/modules/ppcp-settings/src/Data/AbstractDataModel.php index 780ad40bd..070015af2 100644 --- a/modules/ppcp-settings/src/Data/AbstractDataModel.php +++ b/modules/ppcp-settings/src/Data/AbstractDataModel.php @@ -122,5 +122,4 @@ abstract class AbstractDataModel { return $stripped_key ? "set_$stripped_key" : ''; } - } diff --git a/modules/ppcp-settings/src/Data/CommonSettings.php b/modules/ppcp-settings/src/Data/CommonSettings.php deleted file mode 100644 index 1894255ff..000000000 --- a/modules/ppcp-settings/src/Data/CommonSettings.php +++ /dev/null @@ -1,207 +0,0 @@ -woo_settings['country'] = $country; - $this->woo_settings['currency'] = $currency; - } - - /** - * Get default values for the model. - * - * @return array - */ - protected function get_defaults() : array { - return array( - 'use_sandbox' => false, - 'use_manual_connection' => false, - 'client_id' => '', - 'client_secret' => '', - - // Details about connected merchant account. - 'merchant_connected' => false, - 'sandbox_merchant' => false, - 'merchant_id' => '', - 'merchant_email' => '', - ); - } - - // ----- - - /** - * 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 ); - } - - /** - * Returns the list of read-only customization flags. - * - * @return array - */ - public function get_woo_settings() : array { - return $this->woo_settings; - } - - /** - * Setter to update details of the connected merchant account. - * - * Those details cannot be changed individually. - * - * @param bool $is_sandbox Whether the details are for a sandbox account. - * @param string $merchant_id The merchant ID. - * @param string $merchant_email The merchant's email. - * - * @return void - */ - public function set_merchant_data( bool $is_sandbox, string $merchant_id, string $merchant_email ) : void { - $this->data['sandbox_merchant'] = $is_sandbox; - $this->data['merchant_id'] = sanitize_text_field( $merchant_id ); - $this->data['merchant_email'] = sanitize_email( $merchant_email ); - $this->data['merchant_connected'] = true; - } - - /** - * Whether the currently connected merchant is a sandbox account. - * - * @return bool - */ - public function is_sandbox_merchant() : bool { - return $this->data['sandbox_merchant']; - } - - /** - * Whether the merchant successfully logged into their PayPal account. - * - * @return bool - */ - public function is_merchant_connected() : bool { - return $this->data['merchant_connected'] && $this->data['merchant_id'] && $this->data['merchant_email']; - } - - /** - * Gets the currently connected merchant ID. - * - * @return string - */ - public function get_merchant_id() : string { - return $this->data['merchant_id']; - } - - /** - * Gets the currently connected merchant's email. - * - * @return string - */ - public function get_merchant_email() : string { - return $this->data['merchant_email']; - } -} diff --git a/modules/ppcp-settings/src/Data/GeneralSettings.php b/modules/ppcp-settings/src/Data/GeneralSettings.php index 8fe7ee872..94e8a380f 100644 --- a/modules/ppcp-settings/src/Data/GeneralSettings.php +++ b/modules/ppcp-settings/src/Data/GeneralSettings.php @@ -1,6 +1,6 @@ woo_settings['country'] = $country; + $this->woo_settings['currency'] = $currency; + + $this->data['is_send_only_country'] = $is_send_only_country; + $this->data['merchant_connected'] = $this->is_merchant_connected(); + } /** * Get default values for the model. @@ -31,161 +64,150 @@ class GeneralSettings extends AbstractDataModel { */ protected function get_defaults() : array { return array( - 'is_sandbox' => false, - 'live_client_id' => '', - 'live_client_secret' => '', - 'live_merchant_id' => '', - 'live_merchant_email' => '', - 'sandbox_client_id' => '', - 'sandbox_client_secret' => '', - 'sandbox_merchant_id' => '', - 'sandbox_merchant_email' => '', + 'use_sandbox' => false, // UI state, not a connection detail. + 'use_manual_connection' => false, // UI state, not a connection detail. + 'is_send_only_country' => false, // Read-only flag. + + // Details about connected merchant account. + 'merchant_connected' => false, + 'sandbox_merchant' => false, + 'merchant_id' => '', + 'merchant_email' => '', + 'client_id' => '', + 'client_secret' => '', ); } // ----- /** - * Gets the 'is_sandbox' flag. - */ - public function is_sandbox() : bool { - return (bool) $this->data['is_sandbox']; - } - - /** - * Sets the 'is_sandbox' flag. + * Gets the 'use sandbox' setting. * - * @param bool $value The value to set. + * @return bool */ - public function set_is_sandbox( bool $value ) : void { - $this->data['is_sandbox'] = $value; + public function get_sandbox() : bool { + return (bool) $this->data['use_sandbox']; } /** - * Gets the live client ID. - */ - public function live_client_id() : string { - return $this->data['live_client_id']; - } - - /** - * Sets the live client ID. + * Sets the 'use sandbox' setting. * - * @param string $value The value to set. + * @param bool $use_sandbox Whether to use sandbox mode. */ - public function set_live_client_id( string $value ) : void { - $this->data['live_client_id'] = sanitize_text_field( $value ); + public function set_sandbox( bool $use_sandbox ) : void { + $this->data['use_sandbox'] = $use_sandbox; } /** - * Gets the live client secret. - */ - public function live_client_secret() : string { - return $this->data['live_client_secret']; - } - - /** - * Sets the live client secret. + * Gets the 'use manual connection' setting. * - * @param string $value The value to set. + * @return bool */ - public function set_live_client_secret( string $value ) : void { - $this->data['live_client_secret'] = sanitize_text_field( $value ); + public function get_manual_connection() : bool { + return (bool) $this->data['use_manual_connection']; } /** - * Gets the live merchant ID. - */ - public function live_merchant_id() : string { - return $this->data['live_merchant_id']; - } - - /** - * Sets the live merchant ID. + * Sets the 'use manual connection' setting. * - * @param string $value The value to set. + * @param bool $use_manual_connection Whether to use manual connection. */ - public function set_live_merchant_id( string $value ) : void { - $this->data['live_merchant_id'] = sanitize_text_field( $value ); + public function set_manual_connection( bool $use_manual_connection ) : void { + $this->data['use_manual_connection'] = $use_manual_connection; } /** - * Gets the live merchant email. - */ - public function live_merchant_email() : string { - return $this->data['live_merchant_email']; - } - - /** - * Sets the live merchant email. + * Returns the list of read-only customization flags. * - * @param string $value The value to set. + * @return array */ - public function set_live_merchant_email( string $value ) : void { - $this->data['live_merchant_email'] = sanitize_email( $value ); + public function get_woo_settings() : array { + return $this->woo_settings; } /** - * Gets the sandbox client ID. - */ - public function sandbox_client_id() : string { - return $this->data['sandbox_client_id']; - } - - /** - * Sets the sandbox client ID. + * Setter to update details of the connected merchant account. * - * @param string $value The value to set. - */ - public function set_sandbox_client_id( string $value ) : void { - $this->data['sandbox_client_id'] = sanitize_text_field( $value ); - } - - /** - * Gets the sandbox client secret. - */ - public function sandbox_client_secret() : string { - return $this->data['sandbox_client_secret']; - } - - /** - * Sets the sandbox client secret. + * @param MerchantConnectionDTO $connection Connection details. * - * @param string $value The value to set. + * @return void */ - public function set_sandbox_client_secret( string $value ) : void { - $this->data['sandbox_client_secret'] = sanitize_text_field( $value ); + public function set_merchant_data( MerchantConnectionDTO $connection ) : void { + $this->data['sandbox_merchant'] = $connection->is_sandbox; + $this->data['merchant_id'] = sanitize_text_field( $connection->merchant_id ); + $this->data['merchant_email'] = sanitize_email( $connection->merchant_email ); + $this->data['client_id'] = sanitize_text_field( $connection->client_id ); + $this->data['client_secret'] = sanitize_text_field( $connection->client_secret ); + $this->data['merchant_connected'] = $this->is_merchant_connected(); } /** - * Gets the sandbox merchant ID. - */ - public function sandbox_merchant_id() : string { - return $this->data['sandbox_merchant_id']; - } - - /** - * Sets the sandbox merchant ID. + * Returns the full merchant connection DTO for the current connection. * - * @param string $value The value to set. + * @return MerchantConnectionDTO All connection details. */ - public function set_sandbox_merchant_id( string $value ) : void { - $this->data['sandbox_merchant_id'] = sanitize_text_field( $value ); + public function get_merchant_data() : MerchantConnectionDTO { + return new MerchantConnectionDTO( + $this->is_sandbox_merchant(), + $this->data['client_id'], + $this->data['client_secret'], + $this->data['merchant_id'], + $this->data['merchant_email'] + ); } /** - * Gets the sandbox merchant email. - */ - public function sandbox_merchant_email() : string { - return $this->data['sandbox_merchant_email']; - } - - /** - * Sets the sandbox merchant email. + * Reset all connection details to the initial, disconnected state. * - * @param string $value The value to set. + * @return void */ - public function set_sandbox_merchant_email( string $value ) : void { - $this->data['sandbox_merchant_email'] = sanitize_email( $value ); + public function reset_merchant_data() : void { + $defaults = $this->get_defaults(); + + $this->data['sandbox_merchant'] = $defaults['sandbox_merchant']; + $this->data['merchant_id'] = $defaults['merchant_id']; + $this->data['merchant_email'] = $defaults['merchant_email']; + $this->data['client_id'] = $defaults['client_id']; + $this->data['client_secret'] = $defaults['client_secret']; + $this->data['merchant_connected'] = false; + } + + /** + * Whether the currently connected merchant is a sandbox account. + * + * @return bool + */ + public function is_sandbox_merchant() : bool { + return $this->data['sandbox_merchant']; + } + + /** + * Whether the merchant successfully logged into their PayPal account. + * + * @return bool + */ + public function is_merchant_connected() : bool { + return $this->data['merchant_email'] + && $this->data['merchant_id'] + && $this->data['client_id'] + && $this->data['client_secret']; + } + + /** + * Gets the currently connected merchant ID. + * + * @return string + */ + public function get_merchant_id() : string { + return $this->data['merchant_id']; + } + + /** + * Gets the currently connected merchant's email. + * + * @return string + */ + public function get_merchant_email() : string { + return $this->data['merchant_email']; } } diff --git a/modules/ppcp-settings/src/Data/PaymentSettings.php b/modules/ppcp-settings/src/Data/PaymentSettings.php deleted file mode 100644 index 0180150a2..000000000 --- a/modules/ppcp-settings/src/Data/PaymentSettings.php +++ /dev/null @@ -1,34 +0,0 @@ - $settings['invoice_prefix'] ?? '', + 'authorizeOnly' => (bool) ( $settings['authorize_only'] ?? false ), + 'captureVirtualOnlyOrders' => (bool) ( $settings['capture_virtual_only_orders'] ?? false ), + 'savePaypalAndVenmo' => (bool) ( $settings['save_paypal_and_venmo'] ?? false ), + 'saveCreditCardAndDebitCard' => (bool) ( $settings['save_credit_card_and_debit_card'] ?? false ), + 'payNowExperience' => (bool) ( $settings['pay_now_experience'] ?? false ), + 'sandboxAccountCredentials' => (bool) ( $settings['sandbox_account_credentials'] ?? false ), + 'sandboxMode' => $settings['sandbox_mode'] ?? null, + 'sandboxEnabled' => (bool) ( $settings['sandbox_enabled'] ?? false ), + 'sandboxClientId' => $settings['sandbox_client_id'] ?? '', + 'sandboxSecretKey' => $settings['sandbox_secret_key'] ?? '', + 'sandboxConnected' => (bool) ( $settings['sandbox_connected'] ?? false ), + 'logging' => (bool) ( $settings['logging'] ?? false ), + 'subtotalMismatchFallback' => $settings['subtotal_mismatch_fallback'] ?? null, + 'brandName' => $settings['brand_name'] ?? '', + 'softDescriptor' => $settings['soft_descriptor'] ?? '', + 'paypalLandingPage' => $settings['paypal_landing_page'] ?? null, + 'buttonLanguage' => $settings['button_language'] ?? '', + ); + + return $formatted; + } + + /** + * Updates the settings in WordPress options. + * + * Converts the provided data array from camelCase to snake_case format + * and saves it to wp_options table. Throws an exception if update fails. + * + * @param array $data The settings data to update. + * @throws RuntimeException When the settings update fails. + * @return void + */ + public function update( array $data ): void { + $settings = array( + 'invoice_prefix' => $data['invoicePrefix'] ?? '', + 'authorize_only' => (bool) ( $data['authorizeOnly'] ?? false ), + 'capture_virtual_only_orders' => (bool) ( $data['captureVirtualOnlyOrders'] ?? false ), + 'save_paypal_and_venmo' => (bool) ( $data['savePaypalAndVenmo'] ?? false ), + 'save_credit_card_and_debit_card' => (bool) ( $data['saveCreditCardAndDebitCard'] ?? false ), + 'pay_now_experience' => (bool) ( $data['payNowExperience'] ?? false ), + 'sandbox_account_credentials' => (bool) ( $data['sandboxAccountCredentials'] ?? false ), + 'sandbox_mode' => $data['sandboxMode'] ?? null, + 'sandbox_enabled' => (bool) ( $data['sandboxEnabled'] ?? false ), + 'sandbox_client_id' => $data['sandboxClientId'] ?? '', + 'sandbox_secret_key' => $data['sandboxSecretKey'] ?? '', + 'sandbox_connected' => (bool) ( $data['sandboxConnected'] ?? false ), + 'logging' => (bool) ( $data['logging'] ?? false ), + 'subtotal_mismatch_fallback' => $data['subtotalMismatchFallback'] ?? null, + 'brand_name' => $data['brandName'] ?? '', + 'soft_descriptor' => $data['softDescriptor'] ?? '', + 'paypal_landing_page' => $data['paypalLandingPage'] ?? null, + 'button_language' => $data['buttonLanguage'] ?? '', + ); + + $result = update_option( self::OPTION_NAME, $settings ); + + if ( ! $result ) { + throw new RuntimeException( 'Failed to update settings' ); + } + } +} diff --git a/modules/ppcp-settings/src/Data/StylingSettings.php b/modules/ppcp-settings/src/Data/StylingSettings.php index 481625ca7..32fc3de2f 100644 --- a/modules/ppcp-settings/src/Data/StylingSettings.php +++ b/modules/ppcp-settings/src/Data/StylingSettings.php @@ -1,6 +1,6 @@ sanitizer = $sanitizer; + + parent::__construct(); + } /** * Get default values for the model. @@ -29,6 +52,107 @@ class StylingSettings extends AbstractDataModel { * @return array */ protected function get_defaults() : array { - return array(); + return array( + 'cart' => new LocationStylingDTO( 'cart' ), + 'classic_checkout' => new LocationStylingDTO( 'classic_checkout' ), + 'express_checkout' => new LocationStylingDTO( 'express_checkout' ), + 'mini_cart' => new LocationStylingDTO( 'mini_cart' ), + 'product' => new LocationStylingDTO( 'product' ), + ); + } + + /** + * Get styling details for Cart and Block Cart. + * + * @return LocationStylingDTO + */ + public function get_cart() : LocationStylingDTO { + return $this->data['cart']; + } + + /** + * Set styling details for Cart and Block Cart. + * + * @param mixed $styles The new styling details. + * @return void + */ + public function set_cart( $styles ) : void { + $this->data['cart'] = $this->sanitizer->sanitize_location_style( $styles ); + } + + /** + * Get styling details for Classic Checkout. + * + * @return LocationStylingDTO + */ + public function get_classic_checkout() : LocationStylingDTO { + return $this->data['classic_checkout']; + } + + /** + * Set styling details for Classic Checkout. + * + * @param mixed $styles The new styling details. + * @return void + */ + public function set_classic_checkout( $styles ) : void { + $this->data['classic_checkout'] = $this->sanitizer->sanitize_location_style( $styles ); + } + + /** + * Get styling details for Express Checkout. + * + * @return LocationStylingDTO + */ + public function get_express_checkout() : LocationStylingDTO { + return $this->data['express_checkout']; + } + + /** + * Set styling details for Express Checkout. + * + * @param mixed $styles The new styling details. + * @return void + */ + public function set_express_checkout( $styles ) : void { + $this->data['express_checkout'] = $this->sanitizer->sanitize_location_style( $styles ); + } + + /** + * Get styling details for Mini Cart + * + * @return LocationStylingDTO + */ + public function get_mini_cart() : LocationStylingDTO { + return $this->data['mini_cart']; + } + + /** + * Set styling details for Mini Cart. + * + * @param mixed $styles The new styling details. + * @return void + */ + public function set_mini_cart( $styles ) : void { + $this->data['mini_cart'] = $this->sanitizer->sanitize_location_style( $styles ); + } + + /** + * Get styling details for Product Page. + * + * @return LocationStylingDTO + */ + public function get_product() : LocationStylingDTO { + return $this->data['product']; + } + + /** + * Set styling details for Product Page. + * + * @param mixed $styles The new styling details. + * @return void + */ + public function set_product( $styles ) : void { + $this->data['product'] = $this->sanitizer->sanitize_location_style( $styles ); } } diff --git a/modules/ppcp-settings/src/Endpoint/AuthenticationRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/AuthenticationRestEndpoint.php new file mode 100644 index 000000000..7ac964b2d --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/AuthenticationRestEndpoint.php @@ -0,0 +1,219 @@ + array( + 'js_name' => 'merchantId', + ), + 'merchant_email' => array( + 'js_name' => 'email', + ), + ); + + /** + * Constructor. + * + * @param AuthenticationManager $authentication_manager The authentication manager. + */ + public function __construct( AuthenticationManager $authentication_manager ) { + $this->authentication_manager = $authentication_manager; + } + + /** + * Configure REST API routes. + */ + public function register_routes() : void { + /** + * POST /wp-json/wc/v3/wc_paypal/authenticate/direct + * { + * clientId + * clientSecret + * useSandbox + * } + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/direct', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'connect_direct' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'clientId' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'minLength' => 80, + 'maxLength' => 80, + ), + 'clientSecret' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'useSandbox' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + 'sanitize_callback' => array( $this, 'to_boolean' ), + ), + ), + ) + ); + + /** + * POST /wp-json/wc/v3/wc_paypal/authenticate/oauth + * { + * sharedId + * authCode + * useSandbox + * } + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/oauth', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'connect_oauth' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'sharedId' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'authCode' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'useSandbox' => array( + 'default' => 0, + 'type' => 'boolean', + 'sanitize_callback' => array( $this, 'to_boolean' ), + ), + ), + ) + ); + + /** + * POST /wp-json/wc/v3/wc_paypal/authenticate/disconnect + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/disconnect', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'disconnect' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + } + + /** + * Direct login: Retrieves merchantId and email using clientId and clientSecret. + * + * This is the "Manual Login" logic, when a merchant already knows their + * API credentials. + * + * @param WP_REST_Request $request Full data about the request. + */ + public function connect_direct( WP_REST_Request $request ) : WP_REST_Response { + $client_id = $request->get_param( 'clientId' ); + $client_secret = $request->get_param( 'clientSecret' ); + $use_sandbox = $request->get_param( 'useSandbox' ); + + try { + $this->authentication_manager->validate_id_and_secret( $client_id, $client_secret ); + $this->authentication_manager->authenticate_via_direct_api( $use_sandbox, $client_id, $client_secret ); + } catch ( Exception $exception ) { + return $this->return_error( $exception->getMessage() ); + } + + $account = $this->authentication_manager->get_account_details(); + $response = $this->sanitize_for_javascript( $this->response_map, $account ); + + return $this->return_success( $response ); + } + + /** + * OAuth login: Retrieves clientId and clientSecret using a sharedId and authCode. + * + * This is the final step in the UI-driven login via the OAuth popup, which + * is triggered by the LoginLinkRestEndpoint URL. + * + * @param WP_REST_Request $request Full data about the request. + */ + public function connect_oauth( WP_REST_Request $request ) : WP_REST_Response { + $shared_id = $request->get_param( 'sharedId' ); + $auth_code = $request->get_param( 'authCode' ); + $use_sandbox = $request->get_param( 'useSandbox' ); + + try { + $this->authentication_manager->validate_id_and_auth_code( $shared_id, $auth_code ); + $this->authentication_manager->authenticate_via_oauth( $use_sandbox, $shared_id, $auth_code ); + } catch ( Exception $exception ) { + return $this->return_error( $exception->getMessage() ); + } + + $account = $this->authentication_manager->get_account_details(); + $response = $this->sanitize_for_javascript( $this->response_map, $account ); + + return $this->return_success( $response ); + } + + /** + * Disconnect the merchant and clear the authentication details. + * + * @return WP_REST_Response + */ + public function disconnect() : WP_REST_Response { + $this->authentication_manager->disconnect(); + + return $this->return_success( 'OK' ); + } +} diff --git a/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php index 7524e7e31..35af6c093 100644 --- a/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php @@ -12,7 +12,7 @@ namespace WooCommerce\PayPalCommerce\Settings\Endpoint; use WP_REST_Server; use WP_REST_Response; use WP_REST_Request; -use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings; +use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings; /** * REST controller for "common" settings, which are used and modified by @@ -32,9 +32,9 @@ class CommonRestEndpoint extends RestEndpoint { /** * The settings instance. * - * @var CommonSettings + * @var GeneralSettings */ - protected CommonSettings $settings; + protected GeneralSettings $settings; /** * Field mapping for request to profile transformation. @@ -50,13 +50,8 @@ class CommonRestEndpoint extends RestEndpoint { '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', + 'webhooks' => array( + 'js_name' => 'webhooks', ), ); @@ -66,18 +61,27 @@ class CommonRestEndpoint extends RestEndpoint { * @var array */ private array $merchant_info_map = array( - 'merchant_connected' => array( + 'merchant_connected' => array( 'js_name' => 'isConnected', ), - 'sandbox_merchant' => array( + 'sandbox_merchant' => array( 'js_name' => 'isSandbox', ), - 'merchant_id' => array( + 'merchant_id' => array( 'js_name' => 'id', ), - 'merchant_email' => array( + 'merchant_email' => array( 'js_name' => 'email', ), + 'client_id' => array( + 'js_name' => 'clientId', + ), + 'client_secret' => array( + 'js_name' => 'clientSecret', + ), + 'is_send_only_country' => array( + 'js_name' => 'isSendOnlyCountry', + ), ); /** @@ -97,49 +101,55 @@ class CommonRestEndpoint extends RestEndpoint { /** * Constructor. * - * @param CommonSettings $settings The settings instance. + * @param GeneralSettings $settings The settings instance. */ - public function __construct( CommonSettings $settings ) { + public function __construct( GeneralSettings $settings ) { $this->settings = $settings; } /** * Configure REST API routes. */ - public function register_routes() { + public function register_routes() : void { + /** + * GET /wp-json/wc/v3/wc_paypal/common + */ 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' ), - ), + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); + /** + * POST /wp-json/wc/v3/wc_paypal/common + * { + * // Fields mentioned in $field_map[]['js_name'] + * } + */ 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' ), - ), + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); + /** + * GET /wp-json/wc/v3/wc_paypal/common/merchant + */ register_rest_route( $this->namespace, "/$this->rest_base/merchant", array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_merchant_details' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_merchant_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); } @@ -206,10 +216,12 @@ class CommonRestEndpoint extends RestEndpoint { $this->merchant_info_map ); - $extra_data['merchant'] = apply_filters( - 'woocommerce_paypal_payments_rest_common_merchant_data', - $extra_data['merchant'], - ); + if ( $this->settings->is_merchant_connected() ) { + $extra_data['features'] = apply_filters( + 'woocommerce_paypal_payments_rest_common_merchant_data', + array(), + ); + } return $extra_data; } diff --git a/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php deleted file mode 100644 index 7046342a2..000000000 --- a/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php +++ /dev/null @@ -1,238 +0,0 @@ - array( - 'js_name' => 'clientId', - 'sanitize' => 'sanitize_text_field', - ), - 'client_secret' => array( - 'js_name' => 'clientSecret', - 'sanitize' => 'sanitize_text_field', - ), - 'use_sandbox' => array( - 'js_name' => 'useSandbox', - 'sanitize' => 'to_boolean', - ), - ); - - /** - * ConnectManualRestEndpoint constructor. - * - * @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 GeneralSettings $settings Settings instance. - */ - public function __construct( - string $live_host, - string $sandbox_host, - LoggerInterface $logger, - GeneralSettings $settings - ) { - $this->live_host = $live_host; - $this->sandbox_host = $sandbox_host; - $this->logger = $logger; - $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::EDITABLE, - 'callback' => array( $this, 'connect_manual' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), - ) - ); - } - - /** - * Retrieves merchantId and email. - * - * @param WP_REST_Request $request Full data about the request. - */ - public function connect_manual( WP_REST_Request $request ) : WP_REST_Response { - $data = $this->sanitize_for_wordpress( - $request->get_params(), - $this->field_map - ); - - $client_id = $data['client_id'] ?? ''; - $client_secret = $data['client_secret'] ?? ''; - $use_sandbox = (bool) ( $data['use_sandbox'] ?? false ); - - if ( empty( $client_id ) || empty( $client_secret ) ) { - 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 $this->return_error( $exception->getMessage() ); - } - - 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 $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. - * - * @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, - string $client_secret, - bool $use_sandbox - ) : stdClass { - - $host = $use_sandbox ? $this->sandbox_host : $this->live_host; - - $bearer = new PayPalBearer( - new InMemoryCache(), - $host, - $client_id, - $client_secret, - $this->logger, - null - ); - - $orders = new Orders( - $host, - $bearer, - $this->logger - ); - - $request_body = array( - 'intent' => 'CAPTURE', - 'purchase_units' => array( - array( - 'amount' => array( - 'currency_code' => 'USD', - 'value' => 1.0, - ), - ), - ), - ); - - $response = $orders->create( $request_body ); - $body = json_decode( $response['body'] ); - - $order_id = $body->id; - - $order_response = $orders->order( $order_id ); - $order_body = json_decode( $order_response['body'] ); - - $pu = $order_body->purchase_units[0]; - $payee = $pu->payee; - if ( ! is_object( $payee ) ) { - throw new RuntimeException( 'Payee not found.' ); - } - if ( ! isset( $payee->merchant_id ) || ! isset( $payee->email_address ) ) { - throw new RuntimeException( 'Payee info not found.' ); - } - - return $payee; - } -} diff --git a/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php index 8ed204383..c2b0e9ff3 100644 --- a/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php @@ -15,7 +15,14 @@ use WP_REST_Request; use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator; /** - * REST controller that generates merchant login URLs. + * REST controller that generates merchant login URLs for PayPal. + * + * This endpoint is responsible solely for generating a URL that initiates + * the PayPal login flow. It does not handle the authentication itself. + * + * The generated URL is typically used to redirect merchants to PayPal's login page. + * After successful login, the authentication process is completed via the + * AuthenticationRestEndpoint. */ class LoginLinkRestEndpoint extends RestEndpoint { /** @@ -26,48 +33,54 @@ class LoginLinkRestEndpoint extends RestEndpoint { protected $rest_base = 'login_link'; /** - * Link generator list, with environment name as array key. + * Login-URL generator. * - * @var ConnectionUrlGenerator[] + * @var ConnectionUrlGenerator */ - protected array $url_generators; + protected ConnectionUrlGenerator $url_generator; /** * Constructor. * - * @param ConnectionUrlGenerator[] $url_generators Array of environment-specific URL generators. + * @param ConnectionUrlGenerator $url_generator Login-URL generator. */ - public function __construct( array $url_generators ) { - $this->url_generators = $url_generators; + public function __construct( ConnectionUrlGenerator $url_generator ) { + $this->url_generator = $url_generator; } /** * Configure REST API routes. */ - public function register_routes() { + public function register_routes() : void { + /** + * POST /wp-json/wc/v3/wc_paypal/login_link + * { + * useSandbox + * products + * } + */ 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 ); - }, + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'get_login_url' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'useSandbox' => array( + 'default' => 0, + 'type' => 'boolean', + 'sanitize_callback' => array( $this, 'to_boolean' ), + ), + 'products' => array( + 'required' => true, + 'type' => 'array', + 'items' => array( + 'type' => 'string', ), + 'sanitize_callback' => function ( $products ) { + return array_map( 'sanitize_text_field', $products ); + }, ), ), ) @@ -82,20 +95,11 @@ class LoginLinkRestEndpoint extends RestEndpoint { * @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' ); + $use_sandbox = $request->get_param( 'useSandbox' ); $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 ); + $url = $this->url_generator->generate( $products, $use_sandbox ); return $this->return_success( $url ); } catch ( \Exception $e ) { diff --git a/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php index d4273228f..b055ab3e2 100644 --- a/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php @@ -90,34 +90,39 @@ class OnboardingRestEndpoint extends RestEndpoint { public function __construct( OnboardingProfile $profile ) { $this->profile = $profile; - $this->field_map['products']['sanitize'] = fn( $list ) => array_map( 'sanitize_text_field', $list ); + $this->field_map['products']['sanitize'] = static fn( $list ) => array_map( 'sanitize_text_field', $list ); } /** * Configure REST API routes. */ - public function register_routes() { + public function register_routes() : void { + /** + * GET /wp-json/wc/v3/wc_paypal/onboarding + */ 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' ), - ), + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); + /** + * POST /wp-json/wc/v3/wc_paypal/onboarding + * { + * // Fields mentioned in $field_map[]['js_name'] + * } + */ 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' ), - ), + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); } diff --git a/modules/ppcp-settings/src/Endpoint/PaymentRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/PaymentRestEndpoint.php new file mode 100644 index 000000000..29516107f --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/PaymentRestEndpoint.php @@ -0,0 +1,341 @@ + array( + 'id' => 'ppcp-gateway', + 'title' => __( 'PayPal', 'woocommerce-paypal-payments' ), + 'description' => __( + 'Our all-in-one checkout solution lets you offer PayPal, Venmo, Pay Later options, and more to help maximize conversion.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-paypal', + ), + 'venmo' => array( + 'id' => 'venmo', + 'title' => __( 'Venmo', 'woocommerce-paypal-payments' ), + 'description' => __( + 'Offer Venmo at checkout to millions of active users.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-venmo', + ), + 'pay-later' => array( + 'id' => 'paypal_credit', + 'title' => __( 'Pay Later', 'woocommerce-paypal-payments' ), + 'description' => __( + 'Get paid in full at checkout while giving your customers the flexibility to pay in installments over time with no late fees.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-paypal', + ), + CardButtonGateway::ID => array( + 'id' => 'credit_and_debit_card_payments', + 'title' => __( + 'Credit and debit card payments', + 'woocommerce-paypal-payments' + ), + 'description' => __( + "Accept all major credit and debit cards - even if your customer doesn't have a PayPal account.", + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-cards', + ), + + // Online card Payments. + CreditCardGateway::ID => array( + 'id' => 'advanced_credit_and_debit_card_payments', + 'title' => __( + 'Advanced Credit and Debit Card Payments', + 'woocommerce-paypal-payments' + ), + 'description' => __( + "Present custom credit and debit card fields to your payers so they can pay with credit and debit cards using your site's branding.", + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-advanced-cards', + ), + AxoGateway::ID => array( + 'id' => 'fastlane', + 'title' => __( 'Fastlane by PayPal', 'woocommerce-paypal-payments' ), + 'description' => __( + "Tap into the scale and trust of PayPal's customer network to recognize shoppers and make guest checkout more seamless than ever.", + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-fastlane', + ), + ApplePayGateway::ID => array( + 'id' => 'apple_pay', + 'title' => __( 'Apple Pay', 'woocommerce-paypal-payments' ), + 'description' => __( + 'Allow customers to pay via their Apple Pay digital wallet.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-apple-pay', + ), + GooglePayGateway::ID => array( + 'id' => 'google_pay', + 'title' => __( 'Google Pay', 'woocommerce-paypal-payments' ), + 'description' => __( + 'Allow customers to pay via their Google Pay digital wallet.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-google-pay', + ), + + // Alternative payment methods. + BancontactGateway::ID => array( + 'id' => 'bancontact', + 'title' => __( 'Bancontact', 'woocommerce-paypal-payments' ), + 'description' => __( + 'Bancontact is the most widely used, accepted and trusted electronic payment method in Belgium. Bancontact makes it possible to pay directly through the online payment systems of all major Belgian banks.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-bancontact', + ), + BlikGateway::ID => array( + 'id' => 'blik', + 'title' => __( 'BLIK', 'woocommerce-paypal-payments' ), + 'description' => __( + 'A widely used mobile payment method in Poland, allowing Polish customers to pay directly via their banking apps. Transactions are processed in PLN.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-blik', + ), + EPSGateway::ID => array( + 'id' => 'eps', + 'title' => __( 'eps', 'woocommerce-paypal-payments' ), + 'description' => __( + 'An online payment method in Austria, enabling Austrian buyers to make secure payments directly through their bank accounts. Transactions are processed in EUR.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-eps', + ), + IDealGateway::ID => array( + 'id' => 'ideal', + 'title' => __( 'iDEAL', 'woocommerce-paypal-payments' ), + 'description' => __( + 'iDEAL is a payment method in the Netherlands that allows buyers to select their issuing bank from a list of options.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-ideal', + ), + MyBankGateway::ID => array( + 'id' => 'mybank', + 'title' => __( 'MyBank', 'woocommerce-paypal-payments' ), + 'description' => __( + 'A European online banking payment solution primarily used in Italy, enabling customers to make secure bank transfers during checkout. Transactions are processed in EUR.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-mybank', + ), + P24Gateway::ID => array( + 'id' => 'przelewy24', + 'title' => __( 'Przelewy24', 'woocommerce-paypal-payments' ), + 'description' => __( + 'A popular online payment gateway in Poland, offering various payment options for Polish customers. Transactions can be processed in PLN or EUR.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-przelewy24', + ), + TrustlyGateway::ID => array( + 'id' => 'trustly', + 'title' => __( 'Trustly', 'woocommerce-paypal-payments' ), + 'description' => __( + 'A European payment method that allows buyers to make payments directly from their bank accounts, suitable for customers across multiple European countries. Supported currencies include EUR, DKK, SEK, GBP, and NOK.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-trustly', + ), + MultibancoGateway::ID => array( + 'id' => 'multibanco', + 'title' => __( 'Multibanco', 'woocommerce-paypal-payments' ), + 'description' => __( + 'An online payment method in Portugal, enabling Portuguese buyers to make secure payments directly through their bank accounts. Transactions are processed in EUR.', + 'woocommerce-paypal-payments' + ), + 'icon' => 'payment-method-multibanco', + ), + PayUponInvoiceGateway::ID => array( + 'id' => 'pui', + 'title' => __( 'Pay upon Invoice', 'woocommerce-paypal-payments' ), + 'description' => __( + 'Pay upon Invoice is an invoice payment method in Germany. It is a local buy now, pay later payment method that allows the buyer to place an order, receive the goods, try them, verify they are in good order, and then pay the invoice within 30 days.', + 'woocommerce-paypal-payments' + ), + 'icon' => '', + ), + OXXO::ID => array( + 'id' => 'oxxo', + 'title' => __( 'OXXO', 'woocommerce-paypal-payments' ), + 'description' => __( + 'OXXO is a Mexican chain of convenience stores. *Get PayPal account permission to use OXXO payment functionality by contacting us at (+52) 800–925–0304', + 'woocommerce-paypal-payments' + ), + 'icon' => '', + ), + ); + } + + /** + * Configure REST API routes. + */ + public function register_routes() : void { + /** + * GET wc/v3/wc_paypal/payment + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_details' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + /** + * POST wc/v3/wc_paypal/payment + * { + * [gateway_id]: { + * enabled + * title + * description + * } + * } + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_details' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + } + + /** + * Returns all payment methods details. + * + * @return WP_REST_Response The current payment methods details. + */ + public function get_details() : WP_REST_Response { + $all_gateways = WC()->payment_gateways->payment_gateways(); + + $gateway_settings = array(); + + foreach ( $this->gateways() as $key => $value ) { + if ( ! isset( $all_gateways[ $key ] ) ) { + $gateway_settings[ $key ] = array( + 'id' => $this->gateways()[ $key ]['id'] ?? '', + 'title' => $this->gateways()[ $key ]['title'] ?? '', + 'description' => $this->gateways()[ $key ]['description'] ?? '', + 'enabled' => false, + 'icon' => $this->gateways()[ $key ]['icon'] ?? '', + ); + + continue; + } + + $gateway = $all_gateways[ $key ]; + + $gateway_settings[ $key ] = array( + 'enabled' => 'yes' === $gateway->enabled, + 'title' => $this->gateways()[ $key ]['title'] ?? $gateway->get_title(), + 'description' => $this->gateways()[ $key ]['description'] ?? $gateway->get_description(), + 'method_title' => $gateway->get_method_title(), + 'id' => $this->gateways()[ $key ]['id'] ?? $key, + 'icon' => $this->gateways()[ $key ]['icon'] ?? '', + ); + } + + return $this->return_success( $gateway_settings ); + } + + /** + * Updates payment methods details based on the request. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return WP_REST_Response The updated payment methods details. + */ + public function update_details( WP_REST_Request $request ) : WP_REST_Response { + $all_gateways = WC()->payment_gateways->payment_gateways(); + + $request_data = $request->get_params(); + + foreach ( $this->gateways() as $key => $value ) { + // Check if the REST body contains details for this gateway. + if ( ! isset( $request_data[ $key ] ) || ! isset( $all_gateways[ $key ] ) ) { + continue; + } + + $gateway = $all_gateways[ $key ]; + $new_data = $request_data[ $key ]; + + if ( isset( $new_data['enabled'] ) ) { + $gateway->update_option( 'enabled', $new_data['enabled'] ? 'yes' : 'no' ); + } + if ( isset( $new_data['title'] ) ) { + $gateway->update_option( 'title', sanitize_text_field( $new_data['title'] ) ); + } + if ( isset( $new_data['description'] ) ) { + $gateway->update_option( 'description', wp_kses_post( $new_data['description'] ) ); + } + + $gateway->process_admin_options(); + } + + return $this->get_details(); + } +} diff --git a/modules/ppcp-settings/src/Endpoint/RefreshFeatureStatusEndpoint.php b/modules/ppcp-settings/src/Endpoint/RefreshFeatureStatusEndpoint.php index d8fc2760e..3b17b84ed 100644 --- a/modules/ppcp-settings/src/Endpoint/RefreshFeatureStatusEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/RefreshFeatureStatusEndpoint.php @@ -5,7 +5,7 @@ * @package WooCommerce\PayPalCommerce\Settings\Endpoint */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings\Endpoint; @@ -25,7 +25,7 @@ class RefreshFeatureStatusEndpoint extends RestEndpoint { * * @var string */ - protected $rest_base = 'refresh-feature-status'; + protected $rest_base = 'refresh-features'; /** * Cache timeout in seconds. @@ -82,16 +82,17 @@ class RefreshFeatureStatusEndpoint extends RestEndpoint { /** * Configure REST API routes. */ - public function register_routes() { + public function register_routes() : void { + /** + * POST /wp-json/wc/v3/wc_paypal/refresh-features + */ register_rest_route( $this->namespace, '/' . $this->rest_base, array( - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'refresh_status' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'refresh_status' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); } @@ -102,7 +103,7 @@ class RefreshFeatureStatusEndpoint extends RestEndpoint { * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response */ - public function refresh_status( WP_REST_Request $request ): WP_REST_Response { + public function refresh_status( WP_REST_Request $request ) : WP_REST_Response { $now = time(); $last_request_time = $this->cache->get( self::CACHE_KEY ) ?: 0; $seconds_missing = $last_request_time + self::TIMEOUT - $now; diff --git a/modules/ppcp-settings/src/Endpoint/RestEndpoint.php b/modules/ppcp-settings/src/Endpoint/RestEndpoint.php index 76626ac0c..7fba1529c 100644 --- a/modules/ppcp-settings/src/Endpoint/RestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/RestEndpoint.php @@ -81,7 +81,7 @@ abstract class RestEndpoint extends WC_REST_Controller { } /** - * Sanitizes parameters based on a field mapping. + * Sanitizes and renames input parameters, based on a field mapping. * * This method iterates through a field map, applying sanitization methods * to the corresponding values in the input parameters array. @@ -112,9 +112,9 @@ abstract class RestEndpoint extends WC_REST_Controller { if ( null === $sanitation_cb ) { $sanitized[ $key ] = $value; } elseif ( is_string( $sanitation_cb ) && method_exists( $this, $sanitation_cb ) ) { - $sanitized[ $key ] = $this->{$sanitation_cb}( $value ); + $sanitized[ $key ] = $this->{$sanitation_cb}( $value, $key ); } elseif ( is_callable( $sanitation_cb ) ) { - $sanitized[ $key ] = $sanitation_cb( $value ); + $sanitized[ $key ] = $sanitation_cb( $value, $key ); } } @@ -122,7 +122,7 @@ abstract class RestEndpoint extends WC_REST_Controller { } /** - * Sanitizes data for JavaScript based on a field mapping. + * Sanitizes and renames data for JavaScript, based on a field mapping. * * This method transforms the input data array according to the provided field map, * renaming keys to their JavaScript equivalents as specified in the mapping. @@ -151,24 +151,28 @@ abstract class RestEndpoint extends WC_REST_Controller { } /** - * Convert a value to a boolean. + * Sanitation callback: Convert a value to a boolean. * - * @param mixed $value The value to convert. + * @param mixed $value The value to sanitize. * * @return bool|null The boolean value, or null if not set. */ - protected function to_boolean( $value ) : ?bool { + public function to_boolean( $value ) : ?bool { return $value !== null ? (bool) $value : null; } /** - * Convert a value to a number. + * Sanitation callback: Convert a value to a number. * - * @param mixed $value The value to convert. + * @param mixed $value The value to sanitize. * * @return int|float|null The numeric value, or null if not set. */ - protected function to_number( $value ) { - return $value !== null ? ( is_numeric( $value ) ? $value + 0 : null ) : null; + public function to_number( $value ) { + if ( $value !== null ) { + $value = is_numeric( $value ) ? $value + 0 : null; + } + + return $value; } } diff --git a/modules/ppcp-settings/src/Endpoint/SettingsRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/SettingsRestEndpoint.php new file mode 100644 index 000000000..5b82c57d9 --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/SettingsRestEndpoint.php @@ -0,0 +1,163 @@ +settings = $settings; + $this->logger = $logger; + } + + /** + * Registers the REST API routes for settings management. + */ + public function register_routes(): void { + register_rest_route( + $this->namespace, + '/' . self::ENDPOINT, + array( + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_settings' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + array( + 'methods' => 'POST', + 'callback' => array( $this, 'update_settings' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + ) + ); + } + + /** + * Retrieves the current settings. + * + * @param WP_REST_Request $request The request instance. + * @return WP_REST_Response The response containing settings data or error details. + * @throws \Exception When encoding settings data fails. + */ + public function get_settings( WP_REST_Request $request ): WP_REST_Response { + try { + // Get settings data. + $data = $this->settings->get(); + + // Ensure the data is JSON-encodable. + $encoded = wp_json_encode( $data ); + if ( $encoded === false ) { + throw new \Exception( 'Failed to encode settings data: ' . json_last_error_msg() ); + } + + // Create response with pre-verified JSON data. + $response_data = array( + 'success' => true, + 'data' => json_decode( $encoded, true ), + ); + + return new WP_REST_Response( $response_data, 200 ); + } catch ( \Exception $error ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => $error->getMessage(), + ), + 500 + ); + } + } + + /** + * Updates the settings with provided data. + * + * @param WP_REST_Request $request The request instance containing new settings. + * @return WP_REST_Response The response containing updated settings or error details. + * @throws \Exception When encoding updated settings fails. + */ + public function update_settings( WP_REST_Request $request ): WP_REST_Response { + try { + $data = $request->get_json_params(); + $this->settings->update( $data ); + $updated_data = $this->settings->get(); + + // Verify JSON encoding. + $encoded = wp_json_encode( $updated_data ); + if ( $encoded === false ) { + throw new \Exception( 'Failed to encode updated settings: ' . json_last_error_msg() ); + } + + return new WP_REST_Response( + array( + 'success' => true, + 'data' => json_decode( $encoded, true ), + ), + 200 + ); + } catch ( \Exception $error ) { + return new WP_REST_Response( + array( + 'success' => false, + 'message' => $error->getMessage(), + ), + 500 + ); + } + } +} diff --git a/modules/ppcp-settings/src/Endpoint/StylingRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/StylingRestEndpoint.php new file mode 100644 index 000000000..450549e43 --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/StylingRestEndpoint.php @@ -0,0 +1,170 @@ + array( + 'js_name' => 'cart', + ), + 'classic_checkout' => array( + 'js_name' => 'classicCheckout', + ), + 'express_checkout' => array( + 'js_name' => 'expressCheckout', + ), + 'mini_cart' => array( + 'js_name' => 'miniCart', + ), + 'product' => array( + 'js_name' => 'product', + ), + ); + + /** + * Constructor. + * + * @param StylingSettings $settings The settings instance. + * @param DataSanitizer $sanitizer Data sanitizer service. + */ + public function __construct( StylingSettings $settings, DataSanitizer $sanitizer ) { + $this->settings = $settings; + $this->sanitizer = $sanitizer; + + $this->field_map['cart']['sanitize'] = array( + $this->sanitizer, + 'sanitize_location_style', + ); + $this->field_map['classic_checkout']['sanitize'] = array( + $this->sanitizer, + 'sanitize_location_style', + ); + $this->field_map['express_checkout']['sanitize'] = array( + $this->sanitizer, + 'sanitize_location_style', + ); + $this->field_map['mini_cart']['sanitize'] = array( + $this->sanitizer, + 'sanitize_location_style', + ); + $this->field_map['product']['sanitize'] = array( + $this->sanitizer, + 'sanitize_location_style', + ); + } + + /** + * Configure REST API routes. + */ + public function register_routes() : void { + /** + * GET wc/v3/wc_paypal/styling + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_details' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + /** + * POST wc/v3/wc_paypal/styling + * { + * // Fields mentioned in $field_map[]['js_name'] + * } + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_details' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + } + + /** + * Returns all styling details. + * + * @return WP_REST_Response The current styling details. + */ + 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 styling details based on the request. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return WP_REST_Response The updated styling details. + */ + 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/WebhookSettingsEndpoint.php b/modules/ppcp-settings/src/Endpoint/WebhookSettingsEndpoint.php new file mode 100644 index 000000000..81e8f4335 --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/WebhookSettingsEndpoint.php @@ -0,0 +1,210 @@ +webhook_endpoint = $webhook_endpoint; + $this->webhook_registrar = $webhook_registrar; + $this->webhook_simulation = $webhook_simulation; + } + + /** + * Configure REST API routes. + */ + public function register_routes() : void { + /** + * GET /wp-json/wc/v3/wc_paypal/webhooks + * POST /wp-json/wc/v3/wc_paypal/webhooks + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_webhooks' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'resubscribe_webhooks' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + ) + ); + + /** + * GET /wp-json/wc/v3/wc_paypal/webhooks/simulate + * POST /wp-json/wc/v3/wc_paypal/webhooks/simulate + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/simulate', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'check_simulated_webhook_state' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'simulate_webhooks_start' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + ) + ); + } + + /** + * Returns a webhook endpoint URL and list of subscribed webhooks + * + * @return WP_REST_Response + */ + public function get_webhooks() : WP_REST_Response { + $webhooks = $this->get_webhook_data(); + if ( ! $webhooks ) { + return $this->return_error( 'No webhooks found.' ); + } + + try { + $webhook_url = $webhooks->url(); + $webhook_events = array_map( + static fn( stdClass $webhooks ) => strtolower( $webhooks->name ), + $webhooks->event_types() + ); + } catch ( Throwable $error ) { + return $this->return_error( $error->getMessage() ); + } + + return $this->return_success( + array( + 'url' => $webhook_url, + 'events' => $webhook_events, + ) + ); + } + + /** + * Re-subscribes webhooks and returns webhooks + * + * @return WP_REST_Response + */ + public function resubscribe_webhooks() : WP_REST_Response { + if ( ! $this->webhook_registrar->register() ) { + return $this->return_error( 'Webhook subscription failed.' ); + } + + return $this->get_webhooks(); + } + + /** + * Starts webhook simulation + * + * @return WP_REST_Response + */ + public function simulate_webhooks_start() : WP_REST_Response { + try { + $this->webhook_simulation->start(); + + return $this->return_success( array() ); + } catch ( \Exception $error ) { + return $this->return_error( $error->getMessage() ); + } + } + + /** + * Checks webhook simulation state + * + * @return WP_REST_Response + */ + public function check_simulated_webhook_state() : WP_REST_Response { + try { + $state = $this->webhook_simulation->get_state(); + + return $this->return_success( + array( 'state' => $state ) + ); + + } catch ( \Exception $error ) { + return $this->return_error( $error->getMessage() ); + } + } + + + /** + * Retrieves the Webhooks API response object. + * + * @return Webhook|null The webhook data instance, or null. + */ + private function get_webhook_data() : ?Webhook { + try { + $api_response = $this->webhook_endpoint->list(); + + return $api_response[0] ?? null; + } catch ( Throwable $error ) { + return null; + } + } +} diff --git a/modules/ppcp-settings/src/Handler/ConnectionListener.php b/modules/ppcp-settings/src/Handler/ConnectionListener.php index a24a82231..11e946317 100644 --- a/modules/ppcp-settings/src/Handler/ConnectionListener.php +++ b/modules/ppcp-settings/src/Handler/ConnectionListener.php @@ -10,10 +10,13 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings\Handler; -use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings; +use Psr\Log\LoggerInterface; +use RuntimeException; +use WooCommerce\PayPalCommerce\Settings\Service\AuthenticationManager; use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager; use WooCommerce\WooCommerce\Logging\Logger\NullLogger; -use Psr\Log\LoggerInterface; +use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; +use WooCommerce\PayPalCommerce\Http\RedirectorInterface; /** * Provides a listener that handles merchant-connection requests. @@ -31,13 +34,6 @@ class ConnectionListener { */ private string $settings_page_id; - /** - * Access to connection settings. - * - * @var CommonSettings - */ - private CommonSettings $settings; - /** * Access to the onboarding URL manager. * @@ -45,6 +41,21 @@ class ConnectionListener { */ private OnboardingUrlManager $url_manager; + /** + * Authentication manager service, responsible to update connection details. + * + * @var AuthenticationManager + */ + private AuthenticationManager $authentication_manager; + + /** + * A redirector-instance to redirect the merchant after authentication. + * ™ + * + * @var RedirectorInterface + */ + private RedirectorInterface $redirector; + /** * Logger instance, mainly used for debugging purposes. * @@ -62,16 +73,24 @@ class ConnectionListener { /** * Prepare the instance. * - * @param string $settings_page_id Current plugin settings page ID. - * @param CommonSettings $settings Access to saved connection details. - * @param OnboardingUrlManager $url_manager Get OnboardingURL instances. - * @param ?LoggerInterface $logger The logger, for debugging purposes. + * @param string $settings_page_id Current plugin settings page ID. + * @param OnboardingUrlManager $url_manager Get OnboardingURL instances. + * @param AuthenticationManager $authentication_manager Authentication manager service. + * @param RedirectorInterface $redirector Redirect-handler. + * @param ?LoggerInterface $logger The logger, for debugging purposes. */ - public function __construct( string $settings_page_id, CommonSettings $settings, OnboardingUrlManager $url_manager, LoggerInterface $logger = null ) { - $this->settings_page_id = $settings_page_id; - $this->settings = $settings; - $this->url_manager = $url_manager; - $this->logger = $logger ?: new NullLogger(); + public function __construct( + string $settings_page_id, + OnboardingUrlManager $url_manager, + AuthenticationManager $authentication_manager, + RedirectorInterface $redirector, + LoggerInterface $logger = null + ) { + $this->settings_page_id = $settings_page_id; + $this->url_manager = $url_manager; + $this->authentication_manager = $authentication_manager; + $this->redirector = $redirector; + $this->logger = $logger ?: new NullLogger(); // Initialize as "guest", the real ID is provided via process(). $this->user_id = 0; @@ -82,6 +101,8 @@ class ConnectionListener { * * @param int $user_id The current user ID. * @param array $request Request details to process. + * + * @throws RuntimeException If the merchant ID does not match the ID previously set via OAuth. */ public function process( int $user_id, array $request ) : void { $this->user_id = $user_id; @@ -100,13 +121,15 @@ class ConnectionListener { return; } - $this->logger->info( 'Found merchant data in request', $data ); + $this->logger->info( 'Found OAuth merchant data in request', $data ); - $this->store_data( - $data['is_sandbox'], - $data['merchant_id'], - $data['merchant_email'] - ); + try { + $this->authentication_manager->finish_oauth_authentication( $data ); + } catch ( \Exception $e ) { + $this->logger->error( 'Failed to complete authentication: ' . $e->getMessage() ); + } + + $this->redirect_after_authentication(); } /** @@ -117,7 +140,7 @@ class ConnectionListener { * * @return bool True, if the request contains valid connection details. */ - protected function is_valid_request( array $request ) : bool { + private function is_valid_request( array $request ) : bool { if ( $this->user_id < 1 || ! $this->settings_page_id ) { return false; } @@ -149,7 +172,7 @@ class ConnectionListener { * @return array Structured array with 'is_sandbox', 'merchant_id', and 'merchant_email' keys, * or an empty array on failure. */ - protected function extract_data( array $request ) : array { + private function extract_data( array $request ) : array { $this->logger->info( 'Extracting connection data from request...' ); $merchant_id = $this->get_merchant_id_from_request( $request ); @@ -160,24 +183,20 @@ class ConnectionListener { } return array( - 'is_sandbox' => $this->settings->get_sandbox(), 'merchant_id' => $merchant_id, 'merchant_email' => $merchant_email, ); } /** - * Persist the merchant details to the database. + * Redirects the browser page at the end of the authentication flow. * - * @param bool $is_sandbox Whether the details are for a sandbox account. - * @param string $merchant_id The anonymized merchant ID. - * @param string $merchant_email The merchant's email. + * @return void */ - protected function store_data( bool $is_sandbox, string $merchant_id, string $merchant_email ) : void { - $this->logger->info( "Save merchant details to the DB: $merchant_email ($merchant_id)" ); + private function redirect_after_authentication() : void { + $redirect_url = $this->get_onboarding_redirect_url(); - $this->settings->set_merchant_data( $is_sandbox, $merchant_id, $merchant_email ); - $this->settings->save(); + $this->redirector->redirect( $redirect_url ); } /** @@ -187,7 +206,7 @@ class ConnectionListener { * * @return string The sanitized token, or an empty string. */ - protected function get_token_from_request( array $request ) : string { + private function get_token_from_request( array $request ) : string { return $this->sanitize_string( $request['ppcpToken'] ?? '' ); } @@ -198,7 +217,7 @@ class ConnectionListener { * * @return string The sanitized merchant ID, or an empty string. */ - protected function get_merchant_id_from_request( array $request ) : string { + private function get_merchant_id_from_request( array $request ) : string { return $this->sanitize_string( $request['merchantIdInPayPal'] ?? '' ); } @@ -213,7 +232,7 @@ class ConnectionListener { * * @return string The sanitized merchant email, or an empty string. */ - protected function get_merchant_email_from_request( array $request ) : string { + private function get_merchant_email_from_request( array $request ) : string { return $this->sanitize_merchant_email( $request['merchantId'] ?? '' ); } @@ -224,7 +243,7 @@ class ConnectionListener { * * @return string Sanitized value. */ - protected function sanitize_string( string $value ) : string { + private function sanitize_string( string $value ) : string { return trim( sanitize_text_field( wp_unslash( $value ) ) ); } @@ -235,7 +254,22 @@ class ConnectionListener { * * @return string Sanitized email address. */ - protected function sanitize_merchant_email( string $email ) : string { + private function sanitize_merchant_email( string $email ) : string { return sanitize_text_field( str_replace( ' ', '+', $email ) ); } + + /** + * Returns the URL opened at the end of onboarding. + * + * @return string + */ + private function get_onboarding_redirect_url() : string { + /** + * The URL opened at the end of onboarding after saving the merchant ID/email. + */ + return apply_filters( + 'woocommerce_paypal_payments_onboarding_redirect_url', + admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=ppcp-gateway&ppcp-tab=' . Settings::CONNECTION_TAB_ID ) + ); + } } diff --git a/modules/ppcp-settings/src/Service/AuthenticationManager.php b/modules/ppcp-settings/src/Service/AuthenticationManager.php new file mode 100644 index 000000000..95acf068b --- /dev/null +++ b/modules/ppcp-settings/src/Service/AuthenticationManager.php @@ -0,0 +1,439 @@ + + */ + private EnvironmentConfig $connection_host; + + /** + * Login API handler instances, by environment. + * + * @var EnvironmentConfig + */ + private EnvironmentConfig $login_endpoint; + + /** + * Onboarding referrals data. + * + * @var PartnerReferralsData + */ + private PartnerReferralsData $referrals_data; + + /** + * Constructor. + * + * @param GeneralSettings $common_settings Data model that stores the connection details. + * @param EnvironmentConfig $connection_host API host for direct authentication. + * @param EnvironmentConfig $login_endpoint API handler to fetch merchant credentials. + * @param PartnerReferralsData $referrals_data Partner referrals data. + * @param ?LoggerInterface $logger Logging instance. + */ + public function __construct( + GeneralSettings $common_settings, + EnvironmentConfig $connection_host, + EnvironmentConfig $login_endpoint, + PartnerReferralsData $referrals_data, + ?LoggerInterface $logger = null + ) { + $this->common_settings = $common_settings; + $this->connection_host = $connection_host; + $this->login_endpoint = $login_endpoint; + $this->referrals_data = $referrals_data; + $this->logger = $logger ?: new NullLogger(); + } + + /** + * Returns details about the currently connected merchant. + * + * @return array + */ + public function get_account_details() : array { + return array( + 'is_sandbox' => $this->common_settings->is_sandbox_merchant(), + 'is_connected' => $this->common_settings->is_merchant_connected(), + 'merchant_id' => $this->common_settings->get_merchant_id(), + 'merchant_email' => $this->common_settings->get_merchant_email(), + ); + } + + /** + * Removes any connection details we currently have stored. + * + * @return void + */ + public function disconnect() : void { + $this->logger->info( 'Disconnecting merchant from PayPal...' ); + + $this->common_settings->reset_merchant_data(); + $this->common_settings->save(); + + /** + * Broadcast, that the plugin disconnected from PayPal. This allows other + * modules to clean up merchant-related details, such as eligibility flags. + */ + do_action( 'woocommerce_paypal_payments_merchant_disconnected' ); + + /** + * Request to flush caches after disconnecting the merchant. While there + * is no need for it here, it's good house-keeping practice to clean up. + */ + do_action( 'woocommerce_paypal_payments_flush_api_cache' ); + + /** + * Clear the APM eligibility flags from the default settings object. + */ + do_action( 'woocommerce_paypal_payments_clear_apm_product_status', null ); + } + + /** + * Checks if the provided ID and secret have a valid format. + * + * Part of the "Direct Connection" (Manual Connection) flow. + * + * On failure, an Exception is thrown, while a successful check does not + * generate any return value. + * + * @param string $client_id The client ID. + * @param string $client_secret The client secret. + * @return void + * @throws RuntimeException When invalid client ID or secret provided. + */ + public function validate_id_and_secret( string $client_id, string $client_secret ) : void { + if ( empty( $client_id ) ) { + throw new RuntimeException( 'No client ID provided.' ); + } + + if ( false === preg_match( '/^A[\w-]{79}$/', $client_secret ) ) { + throw new RuntimeException( 'Invalid client ID provided.' ); + } + + if ( empty( $client_secret ) ) { + throw new RuntimeException( 'No client secret provided.' ); + } + } + + /** + * Disconnects the current merchant, and then attempts to connect to a + * PayPal account using a client ID and secret. + * + * Part of the "Direct Connection" (Manual Connection) flow. + * + * @param bool $use_sandbox Whether to use the sandbox mode. + * @param string $client_id The client ID. + * @param string $client_secret The client secret. + * @return void + * @throws RuntimeException When failed to retrieve payee. + */ + public function authenticate_via_direct_api( bool $use_sandbox, string $client_id, string $client_secret ) : void { + $this->disconnect(); + + $this->logger->info( + 'Attempting manual connection to PayPal...', + array( + 'sandbox' => $use_sandbox, + 'client_id' => $client_id, + ) + ); + + $payee = $this->request_payee( $client_id, $client_secret, $use_sandbox ); + + $connection = new MerchantConnectionDTO( + $use_sandbox, + $client_id, + $client_secret, + $payee['merchant_id'], + $payee['email_address'] + ); + + $this->update_connection_details( $connection ); + } + + + /** + * Checks if the provided ID and auth-code have a valid format. + * + * Part of the "ISU Connection" (login via Popup) flow. + * + * On failure, an Exception is thrown, while a successful check does not + * generate any return value. Note, that we did not find official documentation + * on those values, so we only check if they are non-empty strings. + * + * @param string $shared_id The shared onboarding ID. + * @param string $auth_code The authorization code. + * @return void + * @throws RuntimeException When invalid shared ID or auth provided. + */ + public function validate_id_and_auth_code( string $shared_id, string $auth_code ) : void { + if ( empty( $shared_id ) ) { + throw new RuntimeException( 'No onboarding ID provided.' ); + } + + if ( empty( $auth_code ) ) { + throw new RuntimeException( 'No authorization code provided.' ); + } + } + + /** + * Disconnects the current merchant, and then attempts to connect to a + * PayPal account the onboarding ID and authorization ID. + * + * Part of the "ISU Connection" (login via Popup) flow. + * + * @param bool $use_sandbox Whether to use the sandbox mode. + * @param string $shared_id The OAuth client ID. + * @param string $auth_code The OAuth authorization code. + * @return void + * @throws RuntimeException When failed to retrieve payee. + */ + public function authenticate_via_oauth( bool $use_sandbox, string $shared_id, string $auth_code ) : void { + $this->disconnect(); + + $this->logger->info( + 'Attempting OAuth login to PayPal...', + array( + 'sandbox' => $use_sandbox, + 'shared_id' => $shared_id, + ) + ); + + $credentials = $this->get_credentials( $shared_id, $auth_code, $use_sandbox ); + + /** + * The merchant's email is set by `ConnectionListener`. That listener + * is invoked during the page reload, once the user clicks the blue + * "Return to Store" button in PayPal's login popup. + */ + $connection = $this->common_settings->get_merchant_data(); + + $connection->is_sandbox = $use_sandbox; + $connection->client_id = $credentials['client_id']; + $connection->client_secret = $credentials['client_secret']; + $connection->merchant_id = $credentials['merchant_id']; + + $this->update_connection_details( $connection ); + } + + /** + * Verifies the merchant details in the final OAuth redirect and extracts + * missing credentials from the URL. + * + * @param array $request_data Array of request parameters to process. + * @return void + * + * @throws RuntimeException Missing or invalid credentials. + */ + public function finish_oauth_authentication( array $request_data ) : void { + $merchant_id = $request_data['merchant_id']; + $merchant_email = $request_data['merchant_email']; + + if ( empty( $merchant_id ) || empty( $merchant_email ) ) { + throw new RuntimeException( 'Missing merchant ID or email in request' ); + } + + $connection = $this->common_settings->get_merchant_data(); + + if ( $connection->merchant_id && $connection->merchant_id !== $merchant_id ) { + throw new RuntimeException( 'Unexpected merchant ID in request' ); + } + + $connection->merchant_id = $merchant_id; + $connection->merchant_email = $merchant_email; + + $this->update_connection_details( $connection ); + } + + + // ---------------------------------------------------------------------------- + // Internal helper methods + + + /** + * Retrieves the payee object with the merchant data by creating a minimal PayPal order. + * + * Part of the "Direct Connection" (Manual Connection) flow. + * + * @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 array Payee details, containing 'merchant_id' and 'merchant_email' keys. + * @throws RuntimeException When failed to retrieve payee. + */ + private function request_payee( + string $client_id, + string $client_secret, + bool $use_sandbox + ) : array { + $host = $this->connection_host->get_value( $use_sandbox ); + + $bearer = new PayPalBearer( + new InMemoryCache(), + $host, + $client_id, + $client_secret, + $this->logger, + null + ); + + $orders = new Orders( + $host, + $bearer, + $this->logger + ); + + $request_body = array( + 'intent' => 'CAPTURE', + 'purchase_units' => array( + array( + 'amount' => array( + 'currency_code' => 'USD', + 'value' => 1.0, + ), + ), + ), + ); + + try { + $response = $orders->create( $request_body ); + $body = json_decode( $response['body'], false, 512, JSON_THROW_ON_ERROR ); + $order_id = $body->id; + + $order_response = $orders->order( $order_id ); + $order_body = json_decode( $order_response['body'], false, 512, JSON_THROW_ON_ERROR ); + } catch ( JsonException $exception ) { + // Cast JsonException to a RuntimeException. + throw new RuntimeException( 'Could not decode JSON response: ' . $exception->getMessage() ); + } catch ( Throwable $exception ) { + // Cast any other Throwable to a RuntimeException. + throw new RuntimeException( $exception->getMessage() ); + } + + $pu = $order_body->purchase_units[0]; + $payee = $pu->payee; + + if ( ! is_object( $payee ) ) { + throw new RuntimeException( 'Payee not found.' ); + } + if ( ! isset( $payee->merchant_id, $payee->email_address ) ) { + throw new RuntimeException( 'Payee info not found.' ); + } + + return array( + 'merchant_id' => $payee->merchant_id, + 'email_address' => $payee->email_address, + ); + } + + /** + * Fetches merchant API credentials using a shared onboarding ID and + * authorization code. + * + * Part of the "ISU Connection" (login via Popup) flow. + * + * @param string $shared_id The shared onboarding ID. + * @param string $auth_code The authorization code. + * @param bool $use_sandbox Whether to use the sandbox mode. + * @return array + * @throws RuntimeException When failed to fetch credentials. + */ + private function get_credentials( string $shared_id, string $auth_code, bool $use_sandbox ) : array { + $login_handler = $this->login_endpoint->get_value( $use_sandbox ); + $nonce = $this->referrals_data->nonce(); + + $response = $login_handler->credentials_for( $shared_id, $auth_code, $nonce ); + + return array( + 'client_id' => (string) ( $response->client_id ?? '' ), + 'client_secret' => (string) ( $response->client_secret ?? '' ), + 'merchant_id' => (string) ( $response->payer_id ?? '' ), + ); + } + + /** + * Stores the provided details in the data model. + * + * @param MerchantConnectionDTO $connection Connection details to persist. + * @return void + */ + private function update_connection_details( MerchantConnectionDTO $connection ) : void { + $this->logger->info( + 'Updating connection details', + (array) $connection + ); + + $this->common_settings->set_merchant_data( $connection ); + $this->common_settings->save(); + + if ( $this->common_settings->is_merchant_connected() ) { + $this->logger->info( 'Merchant successfully connected to PayPal' ); + + /** + * Request to flush caches before authenticating the merchant, to + * ensure the new merchant does not use stale data from previous + * connections. + */ + do_action( 'woocommerce_paypal_payments_flush_api_cache' ); + + /** + * Broadcast that the plugin connected to a new PayPal merchant account. + * This is the right time to initialize merchant relative flags for the + * first time. + */ + do_action( 'woocommerce_paypal_payments_authenticated_merchant' ); + + /** + * Clear the APM eligibility flags from the default settings object. + */ + do_action( 'woocommerce_paypal_payments_clear_apm_product_status', null ); + + /** + * Subscribe the new merchant to relevant PayPal webhooks. + */ + do_action( WebhookRegistrar::EVENT_HOOK ); + } + } +} diff --git a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php index 028740cb9..62ee92dff 100644 --- a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php +++ b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php @@ -12,9 +12,9 @@ namespace WooCommerce\PayPalCommerce\Settings\Service; use Exception; use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals; -use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData; use WooCommerce\WooCommerce\Logging\Logger\NullLogger; +use WooCommerce\PayPalCommerce\WcGateway\Helper\EnvironmentConfig; // TODO: Replace the OnboardingUrl with a new implementation for this module. use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl; @@ -26,9 +26,9 @@ class ConnectionUrlGenerator { /** * The partner referrals endpoint. * - * @var PartnerReferrals + * @var EnvironmentConfig */ - protected PartnerReferrals $partner_referrals; + protected EnvironmentConfig $partner_referrals; /** * The default partner referrals data. @@ -44,13 +44,6 @@ class ConnectionUrlGenerator { */ protected OnboardingUrlManager $url_manager; - /** - * Which environment is used for the connection URL. - * - * @var string - */ - protected string $environment = ''; - /** * The logger * @@ -63,36 +56,23 @@ class ConnectionUrlGenerator { * * Initializes the cache and logger properties of the class. * - * @param PartnerReferrals $partner_referrals PartnerReferrals for URL generation. + * @param EnvironmentConfig $partner_referrals PartnerReferrals for URL generation. * @param PartnerReferralsData $referrals_data Default partner referrals data. - * @param string $environment Environment that is used to generate the URL. - * ['production'|'sandbox']. * @param OnboardingUrlManager $url_manager Manages access to OnboardingUrl instances. * @param ?LoggerInterface $logger The logger object for logging messages. */ public function __construct( - PartnerReferrals $partner_referrals, + EnvironmentConfig $partner_referrals, PartnerReferralsData $referrals_data, - string $environment, OnboardingUrlManager $url_manager, ?LoggerInterface $logger = null ) { $this->partner_referrals = $partner_referrals; $this->referrals_data = $referrals_data; - $this->environment = $environment; $this->url_manager = $url_manager; $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. * @@ -100,13 +80,14 @@ class ConnectionUrlGenerator { * 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. + * @param array $products An array of product identifiers to include in the sign-up process. + * These determine the PayPal onboarding experience. + * @param bool $use_sandbox Whether to generate a sandbox URL. * * @return string The generated PayPal onboarding URL. */ - public function generate( array $products = array() ) : string { - $cache_key = $this->cache_key( $products ); + public function generate( array $products = array(), bool $use_sandbox = false ) : string { + $cache_key = $this->cache_key( $products, $use_sandbox ); $user_id = get_current_user_id(); $onboarding_url = $this->url_manager->get( $cache_key, $user_id ); $cached_url = $this->try_get_from_cache( $onboarding_url, $cache_key ); @@ -119,7 +100,7 @@ class ConnectionUrlGenerator { $this->logger->info( 'Generating onboarding URL for: ' . $cache_key ); - $url = $this->generate_new_url( $products, $onboarding_url, $cache_key ); + $url = $this->generate_new_url( $use_sandbox, $products, $onboarding_url, $cache_key ); if ( $url ) { $this->persist_url( $onboarding_url, $url ); @@ -131,15 +112,18 @@ class ConnectionUrlGenerator { /** * Generates a cache key from the environment and sorted product array. * - * @param array $products Product identifiers that are part of the cache key. + * @param array $products Product identifiers that are part of the cache key. + * @param bool $for_sandbox Whether the cache contains a sandbox URL. * * @return string The cache key, defining the product list and environment. */ - protected function cache_key( array $products = array() ) : string { + protected function cache_key( array $products, bool $for_sandbox ) : string { + $environment = $for_sandbox ? 'sandbox' : 'production'; + // Sort products alphabetically, to improve cache implementation. sort( $products ); - return $this->environment() . '-' . implode( '-', $products ); + return $environment . '-' . implode( '-', $products ); } /** @@ -168,13 +152,14 @@ class ConnectionUrlGenerator { /** * Generates a new URL. * + * @param bool $for_sandbox Whether to generate a sandbox 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 { + protected function generate_new_url( bool $for_sandbox, array $products, OnboardingUrl $onboarding_url, string $cache_key ) : string { $query_args = array( 'displayMode' => 'minibrowser' ); $onboarding_url->init(); @@ -189,7 +174,8 @@ class ConnectionUrlGenerator { $data = $this->prepare_referral_data( $products, $onboarding_token ); try { - $url = $this->partner_referrals->signup_link( $data ); + $referral = $this->partner_referrals->get_value( $for_sandbox ); + $url = $referral->signup_link( $data ); } catch ( Exception $e ) { $this->logger->warning( 'Could not generate an onboarding URL for: ' . $cache_key ); diff --git a/modules/ppcp-settings/src/Service/DataSanitizer.php b/modules/ppcp-settings/src/Service/DataSanitizer.php new file mode 100644 index 000000000..3baab9034 --- /dev/null +++ b/modules/ppcp-settings/src/Service/DataSanitizer.php @@ -0,0 +1,105 @@ +location = $location; + } + + return $data; + } + + if ( is_object( $data ) ) { + $data = (array) $data; + } + + if ( ! is_array( $data ) ) { + return new LocationStylingDTO( $location ?? '' ); + } + + if ( null === $location ) { + $location = $data['location'] ?? ''; + } + + $is_enabled = $this->sanitize_bool( $data['enabled'] ?? true ); + $shape = $this->sanitize_text( $data['shape'] ?? 'rect' ); + $label = $this->sanitize_text( $data['label'] ?? 'pay' ); + $color = $this->sanitize_text( $data['color'] ?? 'gold' ); + $layout = $this->sanitize_text( $data['layout'] ?? 'vertical' ); + $tagline = $this->sanitize_bool( $data['tagline'] ?? false ); + $methods = $this->sanitize_array( + $data['methods'] ?? array(), + array( $this, 'sanitize_text' ) + ); + + return new LocationStylingDTO( + $location, + $is_enabled, + $methods, + $shape, + $label, + $color, + $layout, + $tagline + ); + } + + /** + * Helper. Ensures the value is a string. + * + * @param mixed $value Value to sanitize. + * @param string $default Default value. + * @return string Sanitized string. + */ + protected function sanitize_text( $value, string $default = '' ) : string { + return sanitize_text_field( $value ?? $default ); + } + + /** + * Helper. Ensures the value is a boolean. + * + * @param mixed $value Value to sanitize. + * @return bool Sanitized boolean. + */ + protected function sanitize_bool( $value ) : bool { + return filter_var( $value, FILTER_VALIDATE_BOOLEAN ); + } + + /** + * Helper. Ensures the value is an array and all items are sanitized. + * + * @param null|array $array Value to sanitize. + * @param callable $sanitize_callback Callback to sanitize each item in the array. + * @return array Array with sanitized items. + */ + protected function sanitize_array( ?array $array, callable $sanitize_callback ) : array { + if ( ! is_array( $array ) ) { + return array(); + } + + return array_map( $sanitize_callback, $array ); + } +} diff --git a/modules/ppcp-settings/src/SettingsModule.php b/modules/ppcp-settings/src/SettingsModule.php index ecfa3c662..fc6434755 100644 --- a/modules/ppcp-settings/src/SettingsModule.php +++ b/modules/ppcp-settings/src/SettingsModule.php @@ -9,13 +9,15 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings; +use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint; +use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile; use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint; -use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; +use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; /** * Class SettingsModule @@ -86,7 +88,7 @@ class SettingsModule implements ServiceModule, ExecutableModule { } ); - $endpoint = $container->get( 'settings.switch-ui.endpoint' ) ? $container->get( 'settings.switch-ui.endpoint' ) : null; + $endpoint = $container->get( 'settings.ajax.switch_ui' ) ? $container->get( 'settings.ajax.switch_ui' ) : null; assert( $endpoint instanceof SwitchSettingsUiEndpoint ); add_action( @@ -151,19 +153,45 @@ class SettingsModule implements ServiceModule, ExecutableModule { $style_asset_file['version'] ); + $settings = $container->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + wp_enqueue_style( 'ppcp-admin-settings' ); wp_enqueue_style( 'ppcp-admin-settings-font', 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', array(), $style_asset_file['version'] ); + + $is_pay_later_configurator_available = $container->get( 'paylater-configurator.is-available' ); + + $script_data = array( + 'assets' => array( + 'imagesUrl' => $module_url . '/images/', + ), + 'wcPaymentsTabUrl' => admin_url( 'admin.php?page=wc-settings&tab=checkout' ), + 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + 'isPayLaterConfiguratorAvailable' => $is_pay_later_configurator_available, + ); + + if ( $is_pay_later_configurator_available ) { + wp_enqueue_script( + 'ppcp-paylater-configurator-lib', + 'https://www.paypalobjects.com/merchant-library/merchant-configurator.js', + array(), + $script_asset_file['version'], + true + ); + + $script_data['PcpPayLaterConfigurator'] = array( + 'config' => array(), + 'merchantClientId' => $settings->get( 'client_id' ), + 'partnerClientId' => $container->get( 'api.partner_merchant_id' ), + 'bnCode' => PPCP_PAYPAL_BN_CODE, + ); + } + wp_localize_script( 'ppcp-admin-settings', 'ppcpSettings', - array( - 'assets' => array( - 'imagesUrl' => $module_url . '/images/', - ), - 'wcPaymentsTabUrl' => admin_url( 'admin.php?page=wc-settings&tab=checkout' ), - 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, - ) + $script_data ); } ); @@ -183,11 +211,15 @@ class SettingsModule implements ServiceModule, ExecutableModule { 'rest_api_init', static function () use ( $container ) : void { $endpoints = array( - $container->get( 'settings.rest.onboarding' ), - $container->get( 'settings.rest.common' ), - $container->get( 'settings.rest.connect_manual' ), - $container->get( 'settings.rest.login_link' ), - $container->get( 'settings.rest.refresh_feature_status' ), + 'onboarding' => $container->get( 'settings.rest.onboarding' ), + 'common' => $container->get( 'settings.rest.common' ), + 'connect_manual' => $container->get( 'settings.rest.connect_manual' ), + 'login_link' => $container->get( 'settings.rest.login_link' ), + 'webhooks' => $container->get( 'settings.rest.webhooks' ), + 'refresh_feature_status' => $container->get( 'settings.rest.refresh_feature_status' ), + 'payment' => $container->get( 'settings.rest.payment' ), + 'settings' => $container->get( 'settings.rest.settings' ), + 'styling' => $container->get( 'settings.rest.styling' ), ); foreach ( $endpoints as $endpoint ) { @@ -208,6 +240,29 @@ class SettingsModule implements ServiceModule, ExecutableModule { } ); + add_action( + 'woocommerce_paypal_payments_merchant_disconnected', + static function () use ( $container ) : void { + $onboarding_profile = $container->get( 'settings.data.onboarding' ); + assert( $onboarding_profile instanceof OnboardingProfile ); + + $onboarding_profile->set_completed( false ); + $onboarding_profile->set_step( 0 ); + $onboarding_profile->save(); + } + ); + + add_action( + 'woocommerce_paypal_payments_authenticated_merchant', + static function () use ( $container ) : void { + $onboarding_profile = $container->get( 'settings.data.onboarding' ); + assert( $onboarding_profile instanceof OnboardingProfile ); + + $onboarding_profile->set_completed( true ); + $onboarding_profile->save(); + } + ); + return true; } diff --git a/modules/ppcp-settings/yarn.lock b/modules/ppcp-settings/yarn.lock index 6b623d53a..62a5e4ac9 100644 --- a/modules/ppcp-settings/yarn.lock +++ b/modules/ppcp-settings/yarn.lock @@ -2919,6 +2919,15 @@ sprintf-js "^1.1.1" tannin "^1.2.0" +"@wordpress/icons@^10.14.0": + version "10.14.0" + resolved "https://registry.yarnpkg.com/@wordpress/icons/-/icons-10.14.0.tgz#a27298b438653a9a502eb4ee3b02b42ce516da2e" + integrity sha512-4S1AaBeqvTpsTC23y0+4WPiSyz7j+b7vJ4vQ4nqnPeBF7ZeC8J/UXWQnEuKY38n8TiutXljgagkEqGNC9pF2Mw== + dependencies: + "@babel/runtime" "7.25.7" + "@wordpress/element" "*" + "@wordpress/primitives" "*" + "@wordpress/is-shallow-equal@*": version "5.11.0" resolved "https://registry.yarnpkg.com/@wordpress/is-shallow-equal/-/is-shallow-equal-5.11.0.tgz#2f273d6d4de24a66a7a8316b770cf832d22bfc37" @@ -2968,6 +2977,15 @@ resolved "https://registry.yarnpkg.com/@wordpress/prettier-config/-/prettier-config-4.11.0.tgz#6b3f9aa7e2698c0d78e644037c6778b5c1da12ce" integrity sha512-Aoc8+xWOyiXekodjaEjS44z85XK877LzHZqsQuhC0kNgneDLrKkwI5qNgzwzAMbJ9jI58MPqVISCOX0bDLUPbw== +"@wordpress/primitives@*": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@wordpress/primitives/-/primitives-4.14.0.tgz#1769f45bc541fd48be2d57626a9f6bdece39942a" + integrity sha512-IZibRVbvWoIQ+uynH0N5bmfWz83hD8lJj6jJFhSFuALK+4U5mRGg6tl0ZV0YllR6cjheD9UhTmfrAcOx+gQAjA== + dependencies: + "@babel/runtime" "7.25.7" + "@wordpress/element" "*" + clsx "^2.1.1" + "@wordpress/priority-queue@*": version "3.11.0" resolved "https://registry.yarnpkg.com/@wordpress/priority-queue/-/priority-queue-3.11.0.tgz#01e1570a7a29372bb1d07cd22fd9cbc5b5d03b09" @@ -3976,6 +3994,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.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" diff --git a/modules/ppcp-uninstall/services.php b/modules/ppcp-uninstall/services.php index 51ff935af..629f1164b 100644 --- a/modules/ppcp-uninstall/services.php +++ b/modules/ppcp-uninstall/services.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Uninstall; use WooCommerce\PayPalCommerce\ApiClient\Repository\PayPalRequestIdRepository; -use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; +use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Uninstall\Assets\ClearDatabaseAssets; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway; diff --git a/modules/ppcp-vaulting/src/VaultingModule.php b/modules/ppcp-vaulting/src/VaultingModule.php index 37074b41e..427ff79c1 100644 --- a/modules/ppcp-vaulting/src/VaultingModule.php +++ b/modules/ppcp-vaulting/src/VaultingModule.php @@ -13,6 +13,8 @@ use Psr\Log\LoggerInterface; use RuntimeException; use WC_Payment_Token; use WC_Payment_Tokens; +use WooCommerce\PayPalCommerce\Button\Helper\ContextTrait; +use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; @@ -26,9 +28,18 @@ use WP_User_Query; /** * Class StatusReportModule + * + * @psalm-suppress MissingConstructor */ class VaultingModule implements ServiceModule, ExtendingModule, ExecutableModule { - use ModuleClassNameIdTrait; + use ModuleClassNameIdTrait, ContextTrait; + + /** + * Session Handler + * + * @var SessionHandler + */ + protected SessionHandler $session_handler; /** * {@inheritDoc} @@ -103,6 +114,7 @@ class VaultingModule implements ServiceModule, ExtendingModule, ExecutableModule } ); + $this->session_handler = $container->get( 'session.handler' ); add_filter( 'woocommerce_get_customer_payment_tokens', /** @@ -124,12 +136,18 @@ class VaultingModule implements ServiceModule, ExtendingModule, ExecutableModule && ! $is_post // Don't check on POST so we have all payment methods on form submissions. ) { foreach ( $tokens as $index => $token ) { - if ( $token instanceof PaymentTokenApplePay ) { + if ( $token instanceof PaymentTokenApplePay || $token instanceof PaymentTokenPayPal || $token instanceof PaymentTokenVenmo ) { unset( $tokens[ $index ] ); } } } + if ( is_checkout() && ! $is_post && $this->is_paypal_continuation() ) { + foreach ( $tokens as $index => $token ) { + unset( $tokens[ $index ] ); + } + } + return $tokens; }, 10, diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index 9447c981e..e17c1077d 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -343,9 +343,10 @@ return array( } ), 'wcgateway.notice.connect' => static function ( ContainerInterface $container ): ConnectAdminNotice { - $state = $container->get( 'onboarding.state' ); - $settings = $container->get( 'wcgateway.settings' ); - return new ConnectAdminNotice( $state, $settings ); + $state = $container->get( 'onboarding.state' ); + $settings = $container->get( 'wcgateway.settings' ); + $is_current_country_send_only = $container->get( 'wcgateway.is-send-only-country' ); + return new ConnectAdminNotice( $state, $settings, $is_current_country_send_only ); }, 'wcgateway.notice.currency-unsupported' => static function ( ContainerInterface $container ): UnsupportedCurrencyAdminNotice { $state = $container->get( 'onboarding.state' ); diff --git a/modules/ppcp-wc-gateway/src/Gateway/CardButtonGateway.php b/modules/ppcp-wc-gateway/src/Gateway/CardButtonGateway.php index 3ba41da15..ffb0fe55c 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/CardButtonGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/CardButtonGateway.php @@ -362,4 +362,19 @@ class CardButtonGateway extends \WC_Payment_Gateway { protected function settings_renderer(): SettingsRenderer { return $this->settings_renderer; } + + /** + * Determines if the Gateway is available for use. + * + * @return bool + */ + public function is_available(): bool { + $is_available = parent::is_available(); + + if ( $is_available && $this->is_free_trial_cart() ) { + $is_available = false; + } + + return $is_available; + } } diff --git a/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php b/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php index 5082bd86f..c461c3611 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php @@ -466,7 +466,7 @@ class CreditCardGateway extends \WC_Payment_Gateway_CC { continue; } - $custom_id = $wc_order->get_order_number(); + $custom_id = (string) $wc_order->get_id(); $invoice_id = $this->prefix . $wc_order->get_order_number(); $create_order = $this->capture_card_payment->create_order( $token->get_token(), $custom_id, $invoice_id, $wc_order ); diff --git a/modules/ppcp-wc-gateway/src/Helper/EnvironmentConfig.php b/modules/ppcp-wc-gateway/src/Helper/EnvironmentConfig.php new file mode 100644 index 000000000..1542de783 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Helper/EnvironmentConfig.php @@ -0,0 +1,79 @@ +production_value = $production_value; + $this->sandbox_value = $sandbox_value; + } + + /** + * Factory method to create a validated EnvironmentConfig. + * + * @template U + * @param string $data_type Expected type for the values (class name or primitive type). + * @param U $production_value Value for production environment. + * @param U $sandbox_value Value for the sandbox environment. + * @return self + */ + public static function create( string $data_type, $production_value, $sandbox_value ) : self { + assert( + gettype( $production_value ) === $data_type || $production_value instanceof $data_type, + "Production value must be of type '$data_type'" + ); + assert( + gettype( $sandbox_value ) === $data_type || $sandbox_value instanceof $data_type, + "Sandbox value must be of type '$data_type'" + ); + + return new self( $production_value, $sandbox_value ); + } + + /** + * Get the value for the specified environment. + * + * @param bool $for_sandbox Whether to get the sandbox value. + * @return T The value for the specified environment. + */ + public function get_value( bool $for_sandbox = false ) { + return $for_sandbox ? $this->sandbox_value : $this->production_value; + } +} diff --git a/modules/ppcp-wc-gateway/src/Notice/ConnectAdminNotice.php b/modules/ppcp-wc-gateway/src/Notice/ConnectAdminNotice.php index e8d88adec..60085a992 100644 --- a/modules/ppcp-wc-gateway/src/Notice/ConnectAdminNotice.php +++ b/modules/ppcp-wc-gateway/src/Notice/ConnectAdminNotice.php @@ -33,15 +33,24 @@ class ConnectAdminNotice { */ private $settings; + /** + * Whether the current store's country is classified as a send-only country.. + * + * @var bool + */ + private bool $is_current_country_send_only; + /** * ConnectAdminNotice constructor. * * @param State $state The state. * @param ContainerInterface $settings The settings. + * @param bool $is_current_country_send_only Whether the current store's country is classified as a send-only country. */ - public function __construct( State $state, ContainerInterface $settings ) { - $this->state = $state; - $this->settings = $settings; + public function __construct( State $state, ContainerInterface $settings, bool $is_current_country_send_only ) { + $this->state = $state; + $this->settings = $settings; + $this->is_current_country_send_only = $is_current_country_send_only; } /** @@ -71,6 +80,6 @@ class ConnectAdminNotice { * @return bool */ protected function should_display(): bool { - return $this->state->current_state() !== State::STATE_ONBOARDED; + return $this->state->current_state() !== State::STATE_ONBOARDED && $this->is_current_country_send_only === false; } } diff --git a/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php b/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php index 51a3a741c..c730136ab 100644 --- a/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php +++ b/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php @@ -96,52 +96,35 @@ trait CreditCardOrderInfoHandlingTrait { return; } - $fraud_responses = $fraud->to_array(); $card_brand = $payment_source->properties()->brand ?? __( 'N/A', 'woocommerce-paypal-payments' ); $card_last_digits = $payment_source->properties()->last_digits ?? __( 'N/A', 'woocommerce-paypal-payments' ); - $avs_response_order_note_title = __( 'Address Verification Result', 'woocommerce-paypal-payments' ); + $response_order_note_title = __( 'PayPal Advanced Card Processing Verification:', 'woocommerce-paypal-payments' ); /* translators: %1$s is AVS order note title, %2$s is AVS order note result markup */ - $avs_response_order_note_format = __( '%1$s %2$s', 'woocommerce-paypal-payments' ); - $avs_response_order_note_result_format = '
    -
  • %1$s
  • -
      -
    • %2$s
    • -
    • %3$s
    • -
    -
  • %4$s
  • -
  • %5$s
  • -
'; - $avs_response_order_note_result = sprintf( - $avs_response_order_note_result_format, - /* translators: %s is fraud AVS code */ - sprintf( __( 'AVS: %s', 'woocommerce-paypal-payments' ), esc_html( $fraud_responses['avs_code'] ) ), - /* translators: %s is fraud AVS address match */ - sprintf( __( 'Address Match: %s', 'woocommerce-paypal-payments' ), esc_html( $fraud_responses['address_match'] ) ), - /* translators: %s is fraud AVS postal match */ - sprintf( __( 'Postal Match: %s', 'woocommerce-paypal-payments' ), esc_html( $fraud_responses['postal_match'] ) ), - /* translators: %s is card brand */ - sprintf( __( 'Card Brand: %s', 'woocommerce-paypal-payments' ), esc_html( $card_brand ) ), - /* translators: %s card last digits */ - sprintf( __( 'Card Last Digits: %s', 'woocommerce-paypal-payments' ), esc_html( $card_last_digits ) ) + $response_order_note_format = __( '%1$s %2$s', 'woocommerce-paypal-payments' ); + $response_order_note_result_format = '
    +
  • %1$s
  • +
  • %2$s
  • +
  • %3$s
  • +
'; + $response_order_note_result = sprintf( + $response_order_note_result_format, + /* translators: %1$s is card brand and %2$s card last 4 digits */ + sprintf( __( 'Card: %1$s (%2$s)', 'woocommerce-paypal-payments' ), $card_brand, $card_last_digits ), + /* translators: %s is fraud AVS message */ + sprintf( __( 'AVS: %s', 'woocommerce-paypal-payments' ), $fraud->get_avs_code_message() ), + /* translators: %s is fraud CVV message */ + sprintf( __( 'CVV: %s', 'woocommerce-paypal-payments' ), $fraud->get_cvv2_code_message() ), ); - $avs_response_order_note = sprintf( - $avs_response_order_note_format, - esc_html( $avs_response_order_note_title ), - wp_kses_post( $avs_response_order_note_result ) + $response_order_note = sprintf( + $response_order_note_format, + esc_html( $response_order_note_title ), + wp_kses_post( $response_order_note_result ) ); - $wc_order->add_order_note( $avs_response_order_note ); - - $cvv_response_order_note_format = '
  • %1$s
'; - $cvv_response_order_note = sprintf( - $cvv_response_order_note_format, - /* translators: %s is fraud CVV match */ - sprintf( __( 'CVV2 Match: %s', 'woocommerce-paypal-payments' ), esc_html( $fraud_responses['cvv_match'] ) ) - ); - $wc_order->add_order_note( $cvv_response_order_note ); + $wc_order->add_order_note( $response_order_note ); $meta_details = array_merge( - $fraud_responses, + $fraud->to_array(), array( 'card_brand' => $card_brand, 'card_last_digits' => $card_last_digits, diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php index f754a3cbe..7539f60ee 100644 --- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php +++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php @@ -550,28 +550,24 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul add_filter( 'woocommerce_paypal_payments_rest_common_merchant_data', - function( array $merchant_data ) use ( $c ): array { - if ( ! isset( $merchant_data['features'] ) ) { - $merchant_data['features'] = array(); - } - + function( array $features ) use ( $c ): array { $billing_agreements_endpoint = $c->get( 'api.endpoint.billing-agreements' ); assert( $billing_agreements_endpoint instanceof BillingAgreementsEndpoint ); - $reference_transactions_enabled = $billing_agreements_endpoint->reference_transaction_enabled(); - $merchant_data['features']['save_paypal_and_venmo'] = array( + $reference_transactions_enabled = $billing_agreements_endpoint->reference_transaction_enabled(); + $features['save_paypal_and_venmo'] = array( 'enabled' => $reference_transactions_enabled, ); $dcc_product_status = $c->get( 'wcgateway.helper.dcc-product-status' ); assert( $dcc_product_status instanceof DCCProductStatus ); - $dcc_enabled = $dcc_product_status->dcc_is_active(); - $merchant_data['features']['advanced_credit_and_debit_cards'] = array( + $dcc_enabled = $dcc_product_status->dcc_is_active(); + $features['advanced_credit_and_debit_cards'] = array( 'enabled' => $dcc_enabled, ); - return $merchant_data; + return $features; } ); diff --git a/modules/ppcp-wc-subscriptions/src/RenewalHandler.php b/modules/ppcp-wc-subscriptions/src/RenewalHandler.php index 695bce551..e82491904 100644 --- a/modules/ppcp-wc-subscriptions/src/RenewalHandler.php +++ b/modules/ppcp-wc-subscriptions/src/RenewalHandler.php @@ -257,7 +257,8 @@ class RenewalHandler { // Vault v3. $payment_source = null; - if ( $wc_order->get_payment_method() === PayPalGateway::ID ) { + $payment_method = $wc_order->get_payment_method(); + if ( $payment_method === PayPalGateway::ID ) { $customer_tokens = $this->wc_payment_tokens->customer_tokens( $user_id ); $wc_tokens = WC_Payment_Tokens::get_customer_tokens( $user_id, PayPalGateway::ID ); @@ -309,7 +310,7 @@ class RenewalHandler { } } - if ( $wc_order->get_payment_method() === CreditCardGateway::ID ) { + if ( $payment_method === CreditCardGateway::ID ) { $customer_tokens = $this->wc_payment_tokens->customer_tokens( $user_id ); $wc_tokens = WC_Payment_Tokens::get_customer_tokens( $user_id, CreditCardGateway::ID ); @@ -352,7 +353,7 @@ class RenewalHandler { $this->handle_paypal_order( $wc_order, $order ); - if ( $wc_order->get_payment_method() === CreditCardGateway::ID ) { + if ( $payment_method === CreditCardGateway::ID ) { $card_payment_source = $order->payment_source(); if ( $card_payment_source ) { $wc_tokens = WC_Payment_Tokens::get_customer_tokens( $user_id, CreditCardGateway::ID ); @@ -379,7 +380,7 @@ class RenewalHandler { // Vault v2. $token = $this->get_token_for_customer( $customer, $wc_order ); if ( $token ) { - if ( $wc_order->get_payment_method() === CreditCardGateway::ID ) { + if ( $payment_method === CreditCardGateway::ID ) { $payment_source = $this->card_payment_source( $token->id(), $wc_order ); $order = $this->order_endpoint->create( @@ -406,7 +407,7 @@ class RenewalHandler { return; } - if ( $wc_order->get_payment_method() === PayPalGateway::ID ) { + if ( $payment_method === PayPalGateway::ID || $payment_method === 'ppec_paypal' ) { $order = $this->order_endpoint->create( array( $purchase_unit ), $shipping_preference, diff --git a/package.json b/package.json index 6dc952728..cf4a58b7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-paypal-payments", - "version": "2.9.5", + "version": "2.9.6", "description": "WooCommerce PayPal Payments", "repository": "https://github.com/woocommerce/woocommerce-paypal-payments", "license": "GPL-2.0", diff --git a/readme.txt b/readme.txt index e11d40263..fc2caf2ed 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === WooCommerce PayPal Payments === Contributors: woocommerce, automattic, syde Tags: woocommerce, paypal, payments, ecommerce, credit card -Requires at least: 6.3 +Requires at least: 6.5 Tested up to: 6.7 Requires PHP: 7.4 -Stable tag: 2.9.5 +Stable tag: 2.9.6 License: GPLv2 License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -179,23 +179,35 @@ If you encounter issues with the PayPal buttons not appearing after an update, p == Changelog == += 2.9.6 - 2025-01-06 = +* Fix - NOT_ENABLED_TO_VAULT_PAYMENT_SOURCE on PayPal transactions when using ACDC Vaulting without PayPal Vault approval #2955 +* Fix - Express buttons for Free Trial Subscription products on Block Cart/Checkout trigger CANNOT_BE_ZERO_OR_NEGATIVE error #2872 +* Fix - String translations not applied to Card Fields on Block Checkout #2934 +* Fix - Fastlane component included in script when Fastlane is disabled #2911 +* Fix - Zero amount line items may trigger CANNOT_BE_ZERO_OR_NEGATIVE error after rounding error #2906 +* Fix - “Save changes” is grey and unclickable when switching from Sandbox to Live #2895 +* Fix - plugin queries variations when button/messaging is disabled on single product page #2896 +* Fix - Use get_id instead of get_order_number on setting custom_id (author @0verscore) #2930 +* Enhancement - Improve fraud response order notes for Advanced Card Processing transactions #2905 +* Tweak - Update the minimum plugin requirements to WordPress 6.5 & WooCommerce 9.2 #2920 + = 2.9.5 - 2024-12-10 = -Fix - Early translation loading triggers `Function _load_textdomain_just_in_time was called incorrectly.` notice #2816 -Fix - ACDC card fields not loading and payment not successful when Classic Checkout Smart Button Location disabled #2852 -Fix - ACDC gateway does not appear for guests when is Fastlane enabled and a subscription product is in the cart #2745 -Fix - "Voide authorization" button does not appear for Apple Pay/Google Pay orders when payment buttons are separated #2752 -Fix - Additional payment tokens saved with new customer_id #2820 -Fix - Vaulted payment method may not be displayed in PayPal button for return buyer #2809 -Fix - Conflict with EasyShip plugin due to shipping methods loading too early #2845 -Fix - Restore accidentally removed ACDC currencies #2838 -Enhancement - Native gateway icon for PayPal & Pay upon Invoice gateways #2712 -Enhancement - Allow disabling specific card types for Fastlane #2704 -Enhancement - Fastlane Insights SDK implementation for block Checkout #2737 -Enhancement - Hide split local APMs in Payments settings tab when PayPal is not enabled #2703 -Enhancement - Do not load split local APMs on Checkout when PayPal is not enabled #2792 -Enhancement - Add support for Button Options in the Block Checkout for Apple Pay & Google Pay buttons #2797 #2772 -Enhancement - Disable “Add payment method” button while saving ACDC payment #2794 -Enhancement - Sanitize soft_descriptor field #2846 #2854 +* Fix - Early translation loading triggers `Function _load_textdomain_just_in_time was called incorrectly.` notice #2816 +* Fix - ACDC card fields not loading and payment not successful when Classic Checkout Smart Button Location disabled #2852 +* Fix - ACDC gateway does not appear for guests when is Fastlane enabled and a subscription product is in the cart #2745 +* Fix - "Voide authorization" button does not appear for Apple Pay/Google Pay orders when payment buttons are separated #2752 +* Fix - Additional payment tokens saved with new customer_id #2820 +* Fix - Vaulted payment method may not be displayed in PayPal button for return buyer #2809 +* Fix - Conflict with EasyShip plugin due to shipping methods loading too early #2845 +* Fix - Restore accidentally removed ACDC currencies #2838 +* Enhancement - Native gateway icon for PayPal & Pay upon Invoice gateways #2712 +* Enhancement - Allow disabling specific card types for Fastlane #2704 +* Enhancement - Fastlane Insights SDK implementation for block Checkout #2737 +* Enhancement - Hide split local APMs in Payments settings tab when PayPal is not enabled #2703 +* Enhancement - Do not load split local APMs on Checkout when PayPal is not enabled #2792 +* Enhancement - Add support for Button Options in the Block Checkout for Apple Pay & Google Pay buttons #2797 #2772 +* Enhancement - Disable “Add payment method” button while saving ACDC payment #2794 +* Enhancement - Sanitize soft_descriptor field #2846 #2854 = 2.9.4 - 2024-11-11 = * Fix - Apple Pay button preview missing in Standard payment and Advanced Processing tabs #2755 diff --git a/src/FilePathPluginFactory.php b/src/FilePathPluginFactory.php index e62fe1add..34f028ffa 100644 --- a/src/FilePathPluginFactory.php +++ b/src/FilePathPluginFactory.php @@ -85,7 +85,7 @@ class FilePathPluginFactory implements FilePathPluginFactoryInterface { 'Title' => '', 'Description' => '', 'TextDomain' => '', - 'RequiresWP' => '6.3', + 'RequiresWP' => '6.5', 'RequiresPHP' => '7.4', ), $plugin_data diff --git a/woocommerce-paypal-payments.php b/woocommerce-paypal-payments.php index 1544895ca..d9e1a2277 100644 --- a/woocommerce-paypal-payments.php +++ b/woocommerce-paypal-payments.php @@ -3,14 +3,15 @@ * Plugin Name: WooCommerce PayPal Payments * Plugin URI: https://woocommerce.com/products/woocommerce-paypal-payments/ * Description: PayPal's latest complete payments processing solution. Accept PayPal, Pay Later, credit/debit cards, alternative digital wallets local payment types and bank accounts. Turn on only PayPal options or process a full suite of payment methods. Enable global transaction with extensive currency and country coverage. - * Version: 2.9.5 + * Version: 2.9.6 * Author: WooCommerce * Author URI: https://woocommerce.com/ * License: GPL-2.0 * Requires PHP: 7.4 * Requires Plugins: woocommerce - * WC requires at least: 6.9 - * WC tested up to: 9.4 + * Requires at least: 6.5 + * WC requires at least: 9.2 + * WC tested up to: 9.5 * Text Domain: woocommerce-paypal-payments * * @package WooCommerce\PayPalCommerce @@ -26,7 +27,7 @@ define( 'PAYPAL_API_URL', 'https://api-m.paypal.com' ); define( 'PAYPAL_URL', 'https://www.paypal.com' ); define( 'PAYPAL_SANDBOX_API_URL', 'https://api-m.sandbox.paypal.com' ); define( 'PAYPAL_SANDBOX_URL', 'https://www.sandbox.paypal.com' ); -define( 'PAYPAL_INTEGRATION_DATE', '2024-12-02' ); +define( 'PAYPAL_INTEGRATION_DATE', '2024-12-31' ); define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); ! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' );