diff --git a/modules/ppcp-api-client/src/Endpoint/PartnerReferrals.php b/modules/ppcp-api-client/src/Endpoint/PartnerReferrals.php
index c7f5ec131..1d100cdda 100644
--- a/modules/ppcp-api-client/src/Endpoint/PartnerReferrals.php
+++ b/modules/ppcp-api-client/src/Endpoint/PartnerReferrals.php
@@ -16,6 +16,8 @@
/**
* Class PartnerReferrals
+ *
+ * @see https://developer.paypal.com/docs/api/partner-referrals/v2/
*/
class PartnerReferrals {
diff --git a/modules/ppcp-settings/resources/css/_variables.scss b/modules/ppcp-settings/resources/css/_variables.scss
index 73b656f3a..10f427ea9 100644
--- a/modules/ppcp-settings/resources/css/_variables.scss
+++ b/modules/ppcp-settings/resources/css/_variables.scss
@@ -10,6 +10,7 @@ $color-gray-500: #BBBBBB;
$color-gray-400: #CCCCCC;
$color-gray-300: #EBEBEB;
$color-gray-200: #E0E0E0;
+$color-gray-100: #F0F0F0;
$color-gray: #646970;
$color-text-tertiary: #505050;
$color-text-text: #070707;
@@ -27,6 +28,8 @@ $max-width-settings: 938px;
$card-vertical-gap: 48px;
+/* define custom theming options */
+
:root {
--ppcp-color-app-bg: #{$color-white};
}
@@ -37,4 +40,18 @@ $card-vertical-gap: 48px;
--max-width-onboarding-content: #{$max-width-onboarding-content};
--max-container-width: var(--max-width-settings);
+
+ --color-black: #{$color-black};
+ --color-white: #{$color-white};
+ --color-blueberry: #{$color-blueberry};
+ --color-gray-900: #{$color-gray-900};
+ --color-gray-800: #{$color-gray-800};
+ --color-gray-700: #{$color-gray-700};
+ --color-gray-600: #{$color-gray-600};
+ --color-gray-500: #{$color-gray-500};
+ --color-gray-400: #{$color-gray-400};
+ --color-gray-300: #{$color-gray-300};
+ --color-gray-200: #{$color-gray-200};
+ --color-gray-100: #{$color-gray-100};
+ --color-gradient-dark: #{$color-gradient-dark};
}
diff --git a/modules/ppcp-settings/resources/css/components/_app.scss b/modules/ppcp-settings/resources/css/components/_app.scss
new file mode 100644
index 000000000..7e69cbada
--- /dev/null
+++ b/modules/ppcp-settings/resources/css/components/_app.scss
@@ -0,0 +1,22 @@
+/**
+ * Global app-level styles
+ */
+
+.ppcp-r-app.loading {
+ height: 400px;
+ width: 400px;
+ position: absolute;
+ left: 50%;
+ transform: translate(-50%, 0);
+ text-align: center;
+
+ .ppcp-r-spinner-overlay {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+
+ .ppcp-r-spinner-overlay__message {
+ transform: translate(0, 32px)
+ }
+}
diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_busy-state.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_busy-state.scss
new file mode 100644
index 000000000..4254320aa
--- /dev/null
+++ b/modules/ppcp-settings/resources/css/components/reusable-components/_busy-state.scss
@@ -0,0 +1,10 @@
+.ppcp-r-busy-wrapper {
+ position: relative;
+
+ &.ppcp--is-loading {
+ pointer-events: none;
+ user-select: none;
+
+ --spinner-overlay-color: #fff4;
+ }
+}
diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_button.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_button.scss
index 4174e6a23..558ccaaf2 100644
--- a/modules/ppcp-settings/resources/css/components/reusable-components/_button.scss
+++ b/modules/ppcp-settings/resources/css/components/reusable-components/_button.scss
@@ -1,48 +1,102 @@
+%button-style-default {
+ background-color: var(--button-background);
+ color: var(--button-color);
+ box-shadow: inset 0 0 0 1px var(--button-border-color);
+}
+
+%button-style-hover {
+ background-color: var(--button-hover-background);
+ color: var(--button-hover-color);
+ box-shadow: inset 0 0 0 1px var(--button-hover-border-color);
+}
+
+%button-style-disabled {
+ background-color: var(--button-disabled-background);
+ color: var(--button-disabled-color);
+ box-shadow: inset 0 0 0 1px var(--button-disabled-border-color);
+}
+
+%button-shape-pill {
+ border-radius: 50px;
+ padding: 15px 32px;
+ height: auto;
+}
+
button.components-button, a.components-button {
- &.is-primary, &.is-secondary {
- &:not(:disabled) {
- background-color: $color-black;
- }
+ /* default theme */
+ --button-color: var(--color-gray-900);
+ --button-background: transparent;
+ --button-border-color: transparent;
- &:disabled {
- color: $color-gray-700;
- }
+ --button-hover-color: var(--button-color);
+ --button-hover-background: var(--button-background);
+ --button-hover-border-color: var(--button-border-color);
+
+ --button-disabled-color: var(--color-gray-500);
+ --button-disabled-background: transparent;
+ --button-disabled-border-color: transparent;
+
+ /* style the button template */
- border-radius: 50px;
- padding: 15px 32px;
- height: auto;
+ &:not(:disabled) {
+ @extend %button-style-default;
+ }
+
+ &:hover {
+ @extend %button-style-hover;
+ }
+
+ &:disabled {
+ @extend %button-style-disabled;
+ }
+
+ /*
+ ----------------------------------------------
+ Customize variants using the theming variables
+ */
+
+ &.is-primary,
+ &.is-secondary {
+ @extend %button-shape-pill;
}
&.is-primary {
@include font(14, 18, 900);
- &:not(:disabled) {
- background-color: $color-blueberry;
- color: $color-white;
- }
+ --button-color: #{$color-white};
+ --button-background: #{$color-blueberry};
+
+ --button-disabled-color: #{$color-gray-100};
+ --button-disabled-background: #{$color-gray-500};
}
- &.is-secondary:not(:disabled) {
- border-color: $color-blueberry;
- background-color: $color-white;
- color: $color-blueberry;
+ &.is-secondary {
+ --button-color: #{$color-blueberry};
+ --button-background: #{$color-white};
+ --button-border-color: #{$color-blueberry};
- &:hover {
- background-color: $color-white;
- background: none;
- }
+ --button-disabled-color: #{$color-gray-600};
+ --button-disabled-background: #{$color-gray-100};
+ --button-disabled-border-color: #{$color-gray-400};
}
&.is-tertiary {
- color: $color-blueberry;
-
- &:hover {
- color: $color-gradient-dark;
- }
+ --button-color: #{$color-blueberry};
+ --button-hover-color: #{$color-gradient-dark};
&:focus:not(:disabled) {
border: none;
box-shadow: none;
}
}
+
+ &.small-button {
+ @include small-button;
+ }
+}
+
+.ppcp--is-loading {
+ button.components-button, a.components-button {
+ @extend %button-style-disabled;
+ }
}
diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_settings-toggle-block.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_settings-toggle-block.scss
index e5157a862..af4d264ad 100644
--- a/modules/ppcp-settings/resources/css/components/reusable-components/_settings-toggle-block.scss
+++ b/modules/ppcp-settings/resources/css/components/reusable-components/_settings-toggle-block.scss
@@ -31,10 +31,4 @@
&__toggled-content {
margin-top: 24px;
}
-
- &.ppcp--is-loading {
- pointer-events: none;
-
- --spinner-overlay-color: #fff4;
- }
}
diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_spinner-overlay.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_spinner-overlay.scss
index 2f32b118f..8f5e136e9 100644
--- a/modules/ppcp-settings/resources/css/components/reusable-components/_spinner-overlay.scss
+++ b/modules/ppcp-settings/resources/css/components/reusable-components/_spinner-overlay.scss
@@ -12,5 +12,6 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
+ margin: 0;
}
}
diff --git a/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-welcome.scss b/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-welcome.scss
index 47af9c99a..450251b6f 100644
--- a/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-welcome.scss
+++ b/modules/ppcp-settings/resources/css/components/screens/onboarding/_step-welcome.scss
@@ -20,12 +20,6 @@
margin: 0 0 24px 0;
}
- .ppcp-r-toggle-block__toggled-content > button{
- @include small-button;
- color: $color-white;
- border: none;
- }
-
.client-id-error {
color: #cc1818;
margin: -16px 0 24px;
diff --git a/modules/ppcp-settings/resources/css/style.scss b/modules/ppcp-settings/resources/css/style.scss
index 7ad7aa7a4..56fe55a62 100644
--- a/modules/ppcp-settings/resources/css/style.scss
+++ b/modules/ppcp-settings/resources/css/style.scss
@@ -3,10 +3,11 @@
#ppcp-settings-container {
@import './global';
- @import './components/reusable-components/onboarding-header';
+ @import './components/reusable-components/busy-state';
@import './components/reusable-components/button';
- @import './components/reusable-components/settings-toggle-block';
@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';
@@ -22,6 +23,7 @@
@import './components/screens/onboarding';
@import './components/screens/settings';
@import './components/screens/overview/tab-styling';
+ @import './components/app';
}
@import './components/reusable-components/payment-method-modal';
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js
new file mode 100644
index 000000000..959b71bfe
--- /dev/null
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js
@@ -0,0 +1,68 @@
+import {
+ Children,
+ isValidElement,
+ cloneElement,
+ useMemo,
+ createContext,
+ useContext,
+} from '@wordpress/element';
+import classNames from 'classnames';
+
+import { CommonHooks } from '../../data';
+import SpinnerOverlay from './SpinnerOverlay';
+
+// Create context to track the busy state across nested wrappers
+const BusyContext = createContext( false );
+
+/**
+ * Wraps interactive child elements and modifies their behavior based on the global `isBusy` state.
+ * Allows custom processing of child props via the `onBusy` callback.
+ *
+ * @param {Object} props - Component properties.
+ * @param {Children} props.children - Child components to wrap.
+ * @param {boolean} props.enabled - Enables or disables the busy-state logic.
+ * @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.
+ */
+const BusyStateWrapper = ( {
+ children,
+ enabled = true,
+ busySpinner = true,
+ className = '',
+ onBusy = () => ( { disabled: true } ),
+} ) => {
+ const { isBusy } = CommonHooks.useBusyState();
+ const hasBusyParent = useContext( BusyContext );
+
+ const isBusyComponent = isBusy && enabled;
+ const showSpinner = busySpinner && isBusyComponent && ! hasBusyParent;
+
+ const wrapperClassName = classNames( 'ppcp-r-busy-wrapper', className, {
+ 'ppcp--is-loading': isBusyComponent,
+ } );
+
+ const memoizedChildren = useMemo(
+ () =>
+ Children.map( children, ( child ) =>
+ isValidElement( child )
+ ? cloneElement(
+ child,
+ isBusyComponent ? onBusy( child.props ) : {}
+ )
+ : child
+ ),
+ [ children, isBusyComponent, onBusy ]
+ );
+
+ return (
+
+
+ { showSpinner && }
+ { memoizedChildren }
+
+
+ );
+};
+
+export default BusyStateWrapper;
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js
new file mode 100644
index 000000000..3344c3ceb
--- /dev/null
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js
@@ -0,0 +1 @@
+export { default as openSignup } from './Icons/open-signup';
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
new file mode 100644
index 000000000..83c792f22
--- /dev/null
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons/open-signup.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const openSignup = (
+
+);
+
+export default openSignup;
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/AcdcOptionalPaymentMethods.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/AcdcOptionalPaymentMethods.js
index 733d410de..2abcc37a9 100644
--- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/AcdcOptionalPaymentMethods.js
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/OptionalPaymentMethods/AcdcOptionalPaymentMethods.js
@@ -69,7 +69,6 @@ const AcdcOptionalPaymentMethods = ( {
'woocommerce-paypal-payments'
) }
imageBadge={ [
- 'icon-button-sepa.svg',
'icon-button-ideal.svg',
'icon-button-blik.svg',
'icon-button-bancontact.svg',
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/PaymentMethodIcons.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/PaymentMethodIcons.js
index cd8016a8c..9eaac5d3f 100644
--- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/PaymentMethodIcons.js
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/PaymentMethodIcons.js
@@ -11,7 +11,6 @@ const PaymentMethodIcons = ( props ) => {
-
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsToggleBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsToggleBlock.js
index d8dda1cfb..4a7cf1a20 100644
--- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsToggleBlock.js
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsToggleBlock.js
@@ -1,23 +1,17 @@
import { ToggleControl } from '@wordpress/components';
import { useRef } from '@wordpress/element';
-import SpinnerOverlay from './SpinnerOverlay';
-
const SettingsToggleBlock = ( {
isToggled,
setToggled,
- isLoading = false,
+ disabled = false,
...props
} ) => {
const toggleRef = useRef( null );
const blockClasses = [ 'ppcp-r-toggle-block' ];
- if ( isLoading ) {
- blockClasses.push( 'ppcp--is-loading' );
- }
-
const handleLabelClick = () => {
- if ( ! toggleRef.current || isLoading ) {
+ if ( ! toggleRef.current || disabled ) {
return;
}
@@ -52,13 +46,12 @@ const SettingsToggleBlock = ( {
ref={ toggleRef }
checked={ isToggled }
onChange={ ( newState ) => setToggled( newState ) }
- disabled={ isLoading }
+ disabled={ disabled }
/>
{ props.children && isToggled && (
- { isLoading && }
{ props.children }
) }
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SpinnerOverlay.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SpinnerOverlay.js
index dec732a3e..b4165b5ba 100644
--- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SpinnerOverlay.js
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SpinnerOverlay.js
@@ -1,8 +1,13 @@
import { Spinner } from '@wordpress/components';
-const SpinnerOverlay = () => {
+const SpinnerOverlay = ( { message = '' } ) => {
return (
+ { message && (
+
+ { message }
+
+ ) }
);
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js
index 8d4964ce9..9779e789e 100644
--- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js
@@ -66,7 +66,7 @@ const AcdcFlow = ( {
description={ sprintf(
// translators: %s: Link to PayPal business fees guide
__(
- 'Offer installment payment options and get paid upfront - at no extra cost to you. Learn more',
+ 'Offer installment payment options and get paid upfront. Learn more',
'woocommerce-paypal-payments'
),
'https://www.paypal.com/us/business/paypal-business-fees'
@@ -256,7 +256,7 @@ const AcdcFlow = ( {
description={ sprintf(
// translators: %s: Link to PayPal REST application guide
__(
- 'Offer installment payment options and get paid upfront - at no extra cost to you. Learn more',
+ 'Offer installment payment options and get paid upfront. Learn more',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/BcdcFlow.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/BcdcFlow.js
index 325e40a5e..99abfc4f2 100644
--- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/BcdcFlow.js
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/BcdcFlow.js
@@ -60,7 +60,7 @@ const BcdcFlow = ( { isPayLater, storeCountry, storeCurrency } ) => {
description={ sprintf(
// translators: %s: Link to PayPal REST application guide
__(
- 'Offer installment payment options and get paid upfront - at no extra cost to you. Learn more',
+ 'Offer installment payment options and get paid upfront. Learn more',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
@@ -158,7 +158,7 @@ const BcdcFlow = ( { isPayLater, storeCountry, storeCurrency } ) => {
description={ sprintf(
// translators: %s: Link to PayPal REST application guide
__(
- 'Offer installment payment options and get paid upfront - at no extra cost to you. Learn more',
+ 'Offer installment payment options and get paid upfront. Learn more',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/WelcomeDocs.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/WelcomeDocs.js
index b3b60fee1..eabb5b3db 100644
--- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/WelcomeDocs.js
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/WelcomeDocs.js
@@ -1,7 +1,8 @@
-import { __, sprintf } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import AcdcFlow from './AcdcFlow';
import BcdcFlow from './BcdcFlow';
-import { Button } from '@wordpress/components';
+import { countryPriceInfo } from '../../../utils/countryPriceInfo';
+import { pricesBasedDescription } from './pricesBasedDescription';
const WelcomeDocs = ( {
useAcdc,
@@ -10,15 +11,6 @@ const WelcomeDocs = ( {
storeCountry,
storeCurrency,
} ) => {
- const pricesBasedDescription = sprintf(
- // translators: %s: Link to PayPal REST application guide
- __(
- '1Prices based on domestic transactions as of October 25th, 2024. Click here for full pricing details.',
- 'woocommerce-paypal-payments'
- ),
- 'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
- );
-
return (
@@ -41,10 +33,14 @@ const WelcomeDocs = ( {
storeCurrency={ storeCurrency }
/>
) }
-
+ { storeCountry in countryPriceInfo && (
+
+ ) }
);
};
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/pricesBasedDescription.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/pricesBasedDescription.js
new file mode 100644
index 000000000..c4d3eb983
--- /dev/null
+++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/pricesBasedDescription.js
@@ -0,0 +1,10 @@
+import { __, sprintf } from '@wordpress/i18n';
+
+export const pricesBasedDescription = sprintf(
+ // translators: %s: Link to PayPal REST application guide
+ __(
+ '1Prices based on domestic transactions as of October 25th, 2024. Click here for full pricing details.',
+ 'woocommerce-paypal-payments'
+ ),
+ 'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
+);
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 e7891955b..6aabd15fd 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,96 +1,118 @@
import { __, sprintf } from '@wordpress/i18n';
import { Button, TextControl } from '@wordpress/components';
-import { useRef, useMemo } from '@wordpress/element';
-import { useDispatch } from '@wordpress/data';
-import { store as noticesStore } from '@wordpress/notices';
+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 { openPopup } from '../../../../utils/window';
+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'
+ ),
+};
+
+const AdvancedOptionsForm = () => {
+ const [ clientValid, setClientValid ] = useState( false );
+ const [ secretValid, setSecretValid ] = useState( false );
-const AdvancedOptionsForm = ( { setCompleted } ) => {
const { isBusy } = CommonHooks.useBusyState();
- const { isSandboxMode, setSandboxMode, connectViaSandbox } =
- CommonHooks.useSandbox();
+ const { isSandboxMode, setSandboxMode } = useSandboxConnection();
const {
+ handleConnectViaIdAndSecret,
isManualConnectionMode,
setManualConnectionMode,
clientId,
setClientId,
clientSecret,
setClientSecret,
- connectViaIdAndSecret,
- } = CommonHooks.useManualConnection();
+ } = useManualConnection();
- const { createSuccessNotice, createErrorNotice } =
- useDispatch( noticesStore );
const refClientId = useRef( null );
const refClientSecret = useRef( null );
- const isValidClientId = useMemo( () => {
- return /^A[\w-]{79}$/.test( clientId );
- }, [ clientId ] );
-
- const isFormValid = useMemo( () => {
- return isValidClientId && clientId && clientSecret;
- }, [ isValidClientId, clientId, clientSecret ] );
-
- const handleServerError = ( res, genericMessage ) => {
- console.error( 'Connection error', res );
- createErrorNotice( res?.message ?? genericMessage );
- };
-
- const handleServerSuccess = () => {
- createSuccessNotice(
- __( 'Connected to PayPal', 'woocommerce-paypal-payments' )
- );
- setCompleted( true );
- };
-
- const handleSandboxConnect = async () => {
- const res = await connectViaSandbox();
-
- if ( ! res.success || ! res.data ) {
- handleServerError(
- res,
- __(
- 'Could not generate a Sandbox login link.',
- 'woocommerce-paypal-payments'
- )
- );
- return;
+ const 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 ]
+ );
- const connectionUrl = res.data;
- const popup = openPopup( connectionUrl );
+ 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 ]
+ );
- if ( ! popup ) {
- createErrorNotice(
- __(
- 'Popup blocked. Please allow popups for this site to connect to PayPal.',
- 'woocommerce-paypal-payments'
- )
- );
- }
- };
-
- const handleManualConnect = async () => {
- const res = await connectViaIdAndSecret();
-
- if ( res.success ) {
- handleServerSuccess();
- } else {
- handleServerError(
- res,
- __(
- 'Could not connect to PayPal. Please make sure your Client ID and Secret Key are correct.',
- 'woocommerce-paypal-payments'
- )
- );
- }
- };
+ 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
@@ -103,88 +125,84 @@ const AdvancedOptionsForm = ( { setCompleted } ) => {
return (
<>
-
-
-
+
+
+
+
+
- ( {
+ disabled: true,
+ label: props.label + ' ...',
+ } ) }
>
-
- { clientId && ! isValidClientId && (
-
+
+
+ { clientValid || (
+
+ { FORM_ERRORS.invalidClientId }
+
+ ) }
+
+
- ) }
-
-
-
+
+
+
>
);
};
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
new file mode 100644
index 000000000..ad6a7dcef
--- /dev/null
+++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js
@@ -0,0 +1,49 @@
+import { Button } from '@wordpress/components';
+
+import classNames from 'classnames';
+
+import { CommonHooks } from '../../../../data';
+import { openSignup } from '../../../ReusableComponents/Icons';
+import {
+ useProductionConnection,
+ useSandboxConnection,
+} from '../../../../hooks/useHandleConnections';
+import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
+
+const ConnectionButton = ( {
+ title,
+ isSandbox = false,
+ variant = 'primary',
+ showIcon = true,
+ className = '',
+} ) => {
+ const { handleSandboxConnect } = useSandboxConnection();
+ const { handleProductionConnect } = useProductionConnection();
+ const buttonClassName = classNames( 'ppcp-r-connection-button', className, {
+ 'sandbox-mode': isSandbox,
+ 'live-mode': ! isSandbox,
+ } );
+
+ const handleConnectClick = async () => {
+ if ( isSandbox ) {
+ await handleSandboxConnect();
+ } else {
+ await handleProductionConnect();
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default ConnectionButton;
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 82bfdb656..3c12e1206 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
@@ -6,6 +6,7 @@ import classNames from 'classnames';
import { OnboardingHooks } from '../../../../data';
import useIsScrolled from '../../../../hooks/useIsScrolled';
+import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
const Navigation = ( { stepDetails, onNext, onPrev, onExit } ) => {
const { title, isFirst, percentage, showNext, canProceed } = stepDetails;
@@ -20,7 +21,11 @@ const Navigation = ( { stepDetails, onNext, onPrev, onExit } ) => {
return (
-
+
-
+
{ ! isFirst &&
NextButton( { showNext, isDisabled, onNext, onExit } ) }
@@ -42,7 +47,10 @@ const Navigation = ( { stepDetails, onNext, onPrev, onExit } ) => {
const NextButton = ( { showNext, isDisabled, onNext, onExit } ) => {
return (
-
+
@@ -55,7 +63,7 @@ const NextButton = ( { showNext, isDisabled, onNext, onExit } ) => {
{ __( 'Continue', 'woocommerce-paypal-payments' ) }
) }
-
+
);
};
diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js
index e59bcdeeb..225527053 100644
--- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js
+++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Onboarding.js
@@ -5,8 +5,7 @@ import { getSteps, getCurrentStep } from './availableSteps';
import Navigation from './Components/Navigation';
const Onboarding = () => {
- const { step, setStep, setCompleted, flags } = OnboardingHooks.useSteps();
-
+ const { step, setStep, flags } = OnboardingHooks.useSteps();
const Steps = getSteps( flags );
const currentStep = getCurrentStep( step, Steps );
@@ -30,7 +29,6 @@ const Onboarding = () => {
diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js
index 5f63f923e..ed2001ac2 100644
--- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js
+++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepCompleteSetup.js
@@ -1,28 +1,9 @@
import { __ } from '@wordpress/i18n';
-import { Button, Icon } from '@wordpress/components';
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
+import ConnectionButton from './Components/ConnectionButton';
-const StepCompleteSetup = ( { setCompleted } ) => {
- const ButtonIcon = () => (
-
(
-
- ) }
- />
- );
-
+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/StepPaymentMethods.js
index 83ca5540f..e94f176f7 100644
--- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepPaymentMethods.js
+++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepPaymentMethods.js
@@ -1,10 +1,12 @@
-import { __, sprintf } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import OnboardingHeader from '../../ReusableComponents/OnboardingHeader';
import SelectBoxWrapper from '../../ReusableComponents/SelectBoxWrapper';
import SelectBox from '../../ReusableComponents/SelectBox';
import { CommonHooks, OnboardingHooks } from '../../../data';
import OptionalPaymentMethods from '../../ReusableComponents/OptionalPaymentMethods/OptionalPaymentMethods';
+import { pricesBasedDescription } from '../../ReusableComponents/WelcomeDocs/pricesBasedDescription';
+import { countryPriceInfo } from '../../../utils/countryPriceInfo';
const OPM_RADIO_GROUP_NAME = 'optional-payment-methods';
@@ -16,15 +18,6 @@ const StepPaymentMethods = ( {} ) => {
const { storeCountry, storeCurrency } = CommonHooks.useWooSettings();
- const pricesBasedDescription = sprintf(
- // translators: %s: Link to PayPal REST application guide
- __(
- '1Prices based on domestic transactions as of October 25th, 2024. Click here for full pricing details.',
- 'woocommerce-paypal-payments'
- ),
- 'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
- );
-
return (
{
type="radio"
>
-
+ { storeCountry in countryPriceInfo && (
+
+ ) }
);
diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js
index 761093b24..f8abf9ea5 100644
--- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js
+++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js
@@ -9,9 +9,11 @@ import AccordionSection from '../../ReusableComponents/AccordionSection';
import AdvancedOptionsForm from './Components/AdvancedOptionsForm';
import { CommonHooks } from '../../../data';
+import BusyStateWrapper from '../../ReusableComponents/BusyStateWrapper';
-const StepWelcome = ( { setStep, currentStep, setCompleted } ) => {
+const StepWelcome = ( { setStep, currentStep } ) => {
const { storeCountry, storeCurrency } = CommonHooks.useWooSettings();
+
return (
{
'woocommerce-paypal-payments'
) }
-
+
+
+
{
className="onboarding-advanced-options"
id="advanced-options"
>
-
+
);
diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Settings.js b/modules/ppcp-settings/resources/js/Components/Screens/Settings.js
index faf65c047..bc79b34b3 100644
--- a/modules/ppcp-settings/resources/js/Components/Screens/Settings.js
+++ b/modules/ppcp-settings/resources/js/Components/Screens/Settings.js
@@ -1,6 +1,10 @@
-import { useEffect } from '@wordpress/element';
+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';
@@ -21,16 +25,27 @@ const Settings = () => {
};
}, [] );
- if ( ! onboardingProgress.isReady ) {
- // TODO: Use better loading state indicator.
- return Loading...
;
- }
+ const wrapperClass = classNames( 'ppcp-r-app', {
+ loading: ! onboardingProgress.isReady,
+ } );
+
+ const Content = useMemo( () => {
+ if ( ! onboardingProgress.isReady ) {
+ return (
+
+ );
+ }
+
+ if ( ! onboardingProgress.completed ) {
+ return ;
+ }
- if ( ! onboardingProgress.completed ) {
- return ;
- }
+ return ;
+ }, [ onboardingProgress ] );
- return ;
+ return { Content }
;
};
export default Settings;
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 47de76afe..34e831508 100644
--- a/modules/ppcp-settings/resources/js/data/common/action-types.js
+++ b/modules/ppcp-settings/resources/js/data/common/action-types.js
@@ -10,10 +10,17 @@ export default {
// Persistent data.
SET_PERSISTENT: 'COMMON:SET_PERSISTENT',
+ RESET: 'COMMON:RESET',
HYDRATE: 'COMMON:HYDRATE',
+ // Activity management (advanced solution that replaces the isBusy state).
+ START_ACTIVITY: 'COMMON:START_ACTIVITY',
+ STOP_ACTIVITY: 'COMMON:STOP_ACTIVITY',
+
// 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_REFRESH_MERCHANT: 'COMMON:DO_REFRESH_MERCHANT',
};
diff --git a/modules/ppcp-settings/resources/js/data/common/actions.js b/modules/ppcp-settings/resources/js/data/common/actions.js
index 619aaca5f..7dd13206e 100644
--- a/modules/ppcp-settings/resources/js/data/common/actions.js
+++ b/modules/ppcp-settings/resources/js/data/common/actions.js
@@ -18,6 +18,13 @@ import { STORE_NAME } from './constants';
* @property {Object?} payload - Optional payload for the action.
*/
+/**
+ * Special. Resets all values in the onboarding store to initial defaults.
+ *
+ * @return {Action} The action.
+ */
+export const reset = () => ( { type: ACTION_TYPES.RESET } );
+
/**
* Persistent. Set the full onboarding details, usually during app initialization.
*
@@ -52,14 +59,35 @@ export const setIsSaving = ( isSaving ) => ( {
} );
/**
- * Transient. Changes the "manual connection is busy" flag.
+ * Transient (Activity): Marks the start of an async activity
+ * Think of it as "setIsBusy(true)"
+ *
+ * @param {string} id Internal ID/key of the action, used to stop it again.
+ * @param {?string} description Optional, description for logging/debugging
+ * @return {?Action} The action.
+ */
+export const startActivity = ( id, description = null ) => {
+ if ( ! id || 'string' !== typeof id ) {
+ console.warn( 'Activity ID must be a non-empty string' );
+ return null;
+ }
+
+ return {
+ type: ACTION_TYPES.START_ACTIVITY,
+ payload: { id, description },
+ };
+};
+
+/**
+ * Transient (Activity): Marks the end of an async activity.
+ * Think of it as "setIsBusy(false)"
*
- * @param {boolean} isBusy
+ * @param {string} id Internal ID/key of the action, used to stop it again.
* @return {Action} The action.
*/
-export const setIsBusy = ( isBusy ) => ( {
- type: ACTION_TYPES.SET_TRANSIENT,
- payload: { isBusy },
+export const stopActivity = ( id ) => ( {
+ type: ACTION_TYPES.STOP_ACTIVITY,
+ payload: { id },
} );
/**
@@ -118,17 +146,22 @@ export const persist = function* () {
};
/**
- * Side effect. Initiates the sandbox login ISU.
+ * Side effect. Fetches the ISU-login URL for a sandbox account.
*
* @return {Action} The action.
*/
-export const connectViaSandbox = function* () {
- yield setIsBusy( true );
-
- const result = yield { type: ACTION_TYPES.DO_SANDBOX_LOGIN };
- yield setIsBusy( false );
+export const connectToSandbox = function* () {
+ return yield { type: ACTION_TYPES.DO_SANDBOX_LOGIN };
+};
- return result;
+/**
+ * Side effect. Fetches the ISU-login URL for a production account.
+ *
+ * @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 };
};
/**
@@ -140,15 +173,19 @@ export const connectViaIdAndSecret = function* () {
const { clientId, clientSecret, useSandbox } =
yield select( STORE_NAME ).persistentData();
- yield setIsBusy( true );
-
- const result = yield {
+ return yield {
type: ACTION_TYPES.DO_MANUAL_CONNECTION,
clientId,
clientSecret,
useSandbox,
};
- yield setIsBusy( false );
+};
- return result;
+/**
+ * Side effect. Clears and refreshes the merchant data via a REST request.
+ *
+ * @return {Action} The action.
+ */
+export const refreshMerchantData = function* () {
+ return yield { type: ACTION_TYPES.DO_REFRESH_MERCHANT };
};
diff --git a/modules/ppcp-settings/resources/js/data/common/constants.js b/modules/ppcp-settings/resources/js/data/common/constants.js
index c7ea9b4c1..9499ef069 100644
--- a/modules/ppcp-settings/resources/js/data/common/constants.js
+++ b/modules/ppcp-settings/resources/js/data/common/constants.js
@@ -8,7 +8,7 @@
export const STORE_NAME = 'wc/paypal/common';
/**
- * 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.
*
@@ -16,6 +16,15 @@ export const STORE_NAME = 'wc/paypal/common';
*/
export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/common';
+/**
+ * REST path to fetch merchant details from the WordPress DB.
+ *
+ * Used by controls.
+ *
+ * @type {string}
+ */
+export const REST_HYDRATE_MERCHANT_PATH = '/wc/v3/wc_paypal/common/merchant';
+
/**
* REST path to persist data of this module to the WP DB.
*
@@ -36,11 +45,11 @@ export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/common';
export const REST_MANUAL_CONNECTION_PATH = '/wc/v3/wc_paypal/connect_manual';
/**
- * REST path to generate an ISU URL for the sandbox-login.
+ * REST path to generate an ISU URL for the PayPal-login.
*
* Used by: Controls
* See: LoginLinkRestEndpoint.php
*
* @type {string}
*/
-export const REST_SANDBOX_CONNECTION_PATH = '/wc/v3/wc_paypal/login_link';
+export const REST_CONNECTION_URL_PATH = '/wc/v3/wc_paypal/login_link';
diff --git a/modules/ppcp-settings/resources/js/data/common/controls.js b/modules/ppcp-settings/resources/js/data/common/controls.js
index 6de513e0b..7845f335f 100644
--- a/modules/ppcp-settings/resources/js/data/common/controls.js
+++ b/modules/ppcp-settings/resources/js/data/common/controls.js
@@ -7,12 +7,15 @@
* @file
*/
+import { dispatch } from '@wordpress/data';
import apiFetch from '@wordpress/api-fetch';
import {
+ STORE_NAME,
REST_PERSIST_PATH,
REST_MANUAL_CONNECTION_PATH,
- REST_SANDBOX_CONNECTION_PATH,
+ REST_CONNECTION_URL_PATH,
+ REST_HYDRATE_MERCHANT_PATH,
} from './constants';
import ACTION_TYPES from './action-types';
@@ -34,11 +37,33 @@ export const controls = {
try {
result = await apiFetch( {
- path: REST_SANDBOX_CONNECTION_PATH,
+ path: REST_CONNECTION_URL_PATH,
method: 'POST',
data: {
environment: 'sandbox',
- products: [ 'EXPRESS_CHECKOUT' ],
+ products: [ 'EXPRESS_CHECKOUT' ], // Sandbox always uses EXPRESS_CHECKOUT.
+ },
+ } );
+ } catch ( e ) {
+ result = {
+ success: false,
+ error: e,
+ };
+ }
+
+ return result;
+ },
+
+ async [ ACTION_TYPES.DO_PRODUCTION_LOGIN ]( { products } ) {
+ let result = null;
+
+ try {
+ result = await apiFetch( {
+ path: REST_CONNECTION_URL_PATH,
+ method: 'POST',
+ data: {
+ environment: 'production',
+ products,
},
} );
} catch ( e ) {
@@ -77,4 +102,23 @@ export const controls = {
return result;
},
+
+ async [ ACTION_TYPES.DO_REFRESH_MERCHANT ]() {
+ let result = null;
+
+ try {
+ result = await apiFetch( { path: REST_HYDRATE_MERCHANT_PATH } );
+
+ if ( result.success && result.merchant ) {
+ await dispatch( STORE_NAME ).hydrate( result );
+ }
+ } catch ( e ) {
+ result = {
+ success: false,
+ error: e,
+ };
+ }
+
+ return result;
+ },
};
diff --git a/modules/ppcp-settings/resources/js/data/common/hooks.js b/modules/ppcp-settings/resources/js/data/common/hooks.js
index fbe6a4842..8eaaa3924 100644
--- a/modules/ppcp-settings/resources/js/data/common/hooks.js
+++ b/modules/ppcp-settings/resources/js/data/common/hooks.js
@@ -31,7 +31,8 @@ const useHooks = () => {
setManualConnectionMode,
setClientId,
setClientSecret,
- connectViaSandbox,
+ connectToSandbox,
+ connectToProduction,
connectViaIdAndSecret,
} = useDispatch( STORE_NAME );
@@ -44,6 +45,10 @@ const useHooks = () => {
const isSandboxMode = usePersistent( 'useSandbox' );
const isManualConnectionMode = usePersistent( 'useManualConnection' );
+ const merchant = useSelect(
+ ( select ) => select( STORE_NAME ).merchant(),
+ []
+ );
const wooSettings = useSelect(
( select ) => select( STORE_NAME ).wooSettings(),
[]
@@ -72,26 +77,24 @@ const useHooks = () => {
setClientSecret: ( value ) => {
return savePersistent( setClientSecret, value );
},
- connectViaSandbox,
+ connectToSandbox,
+ connectToProduction,
connectViaIdAndSecret,
+ merchant,
wooSettings,
};
};
-export const useBusyState = () => {
- const { setIsBusy } = useDispatch( STORE_NAME );
- const isBusy = useTransient( 'isBusy' );
+export const useSandbox = () => {
+ const { isSandboxMode, setSandboxMode, connectToSandbox } = useHooks();
- return {
- isBusy,
- setIsBusy: useCallback( ( busy ) => setIsBusy( busy ), [ setIsBusy ] ),
- };
+ return { isSandboxMode, setSandboxMode, connectToSandbox };
};
-export const useSandbox = () => {
- const { isSandboxMode, setSandboxMode, connectViaSandbox } = useHooks();
+export const useProduction = () => {
+ const { connectToProduction } = useHooks();
- return { isSandboxMode, setSandboxMode, connectViaSandbox };
+ return { connectToProduction };
};
export const useManualConnection = () => {
@@ -118,5 +121,61 @@ export const useManualConnection = () => {
export const useWooSettings = () => {
const { wooSettings } = useHooks();
+
return wooSettings;
};
+
+export const useMerchantInfo = () => {
+ const { merchant } = useHooks();
+ const { refreshMerchantData } = useDispatch( STORE_NAME );
+
+ const verifyLoginStatus = useCallback( async () => {
+ const result = await refreshMerchantData();
+
+ if ( ! result.success ) {
+ throw new Error( result?.message || result?.error?.message );
+ }
+
+ // Verify if the server state is "connected" and we have a merchant ID.
+ return merchant?.isConnected && merchant?.id;
+ }, [ refreshMerchantData, merchant ] );
+
+ return {
+ merchant, // Merchant details
+ verifyLoginStatus, // Callback
+ };
+};
+
+// -- Not using the `useHooks()` data provider --
+
+export const useBusyState = () => {
+ const { startActivity, stopActivity } = useDispatch( STORE_NAME );
+
+ // Resolved value (object), contains a list of all running actions.
+ const activities = useSelect(
+ ( select ) => select( STORE_NAME ).getActivityList(),
+ []
+ );
+
+ // Derive isBusy state from activities
+ const isBusy = Object.keys( activities ).length > 0;
+
+ // HOC that starts and stops an activity while the callback is executed.
+ const withActivity = useCallback(
+ async ( id, description, asyncFn ) => {
+ startActivity( id, description );
+ try {
+ return await asyncFn();
+ } finally {
+ stopActivity( id );
+ }
+ },
+ [ startActivity, stopActivity ]
+ );
+
+ return {
+ withActivity, // HOC
+ isBusy, // Boolean.
+ activities, // Object.
+ };
+};
diff --git a/modules/ppcp-settings/resources/js/data/common/reducer.js b/modules/ppcp-settings/resources/js/data/common/reducer.js
index 771dfa8f5..7d3f5697f 100644
--- a/modules/ppcp-settings/resources/js/data/common/reducer.js
+++ b/modules/ppcp-settings/resources/js/data/common/reducer.js
@@ -12,23 +12,30 @@ import ACTION_TYPES from './action-types';
// Store structure.
-const defaultTransient = {
+const defaultTransient = Object.freeze( {
isReady: false,
- isBusy: false,
+ activities: new Map(),
// Read only values, provided by the server via hydrate.
- wooSettings: {
+ merchant: Object.freeze( {
+ isConnected: false,
+ isSandbox: false,
+ id: '',
+ email: '',
+ } ),
+
+ wooSettings: Object.freeze( {
storeCountry: '',
storeCurrency: '',
- },
-};
+ } ),
+} );
-const defaultPersistent = {
+const defaultPersistent = Object.freeze( {
useSandbox: false,
useManualConnection: false,
clientId: '',
clientSecret: '',
-};
+} );
// Reducer logic.
@@ -44,15 +51,53 @@ const commonReducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, action ) =>
setPersistent( state, action ),
+ [ ACTION_TYPES.RESET ]: ( state ) => {
+ const cleanState = setTransient(
+ setPersistent( state, defaultPersistent ),
+ defaultTransient
+ );
+
+ // Keep "read-only" details and initialization flags.
+ cleanState.wooSettings = { ...state.wooSettings };
+ cleanState.isReady = true;
+
+ return cleanState;
+ },
+
+ [ ACTION_TYPES.START_ACTIVITY ]: ( state, payload ) => {
+ return setTransient( state, {
+ activities: new Map( state.activities ).set(
+ payload.id,
+ payload.description
+ ),
+ } );
+ },
+
+ [ ACTION_TYPES.STOP_ACTIVITY ]: ( state, payload ) => {
+ const newActivities = new Map( state.activities );
+ newActivities.delete( payload.id );
+ return setTransient( state, { activities: newActivities } );
+ },
+
+ [ ACTION_TYPES.DO_REFRESH_MERCHANT ]: ( state ) => ( {
+ ...state,
+ merchant: Object.freeze( { ...defaultTransient.merchant } ),
+ } ),
+
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
const newState = setPersistent( state, payload.data );
- if ( payload.wooSettings ) {
- newState.wooSettings = {
- ...newState.wooSettings,
- ...payload.wooSettings,
- };
- }
+ // Populate read-only properties.
+ [ 'wooSettings', 'merchant' ].forEach( ( key ) => {
+ if ( ! payload[ key ] ) {
+ return;
+ }
+
+ newState[ key ] = Object.freeze( {
+ ...newState[ key ],
+ ...payload[ key ],
+ } );
+ } );
return newState;
},
diff --git a/modules/ppcp-settings/resources/js/data/common/selectors.js b/modules/ppcp-settings/resources/js/data/common/selectors.js
index 7f0b3ee20..fde5d8c9e 100644
--- a/modules/ppcp-settings/resources/js/data/common/selectors.js
+++ b/modules/ppcp-settings/resources/js/data/common/selectors.js
@@ -16,10 +16,20 @@ export const persistentData = ( state ) => {
};
export const transientData = ( state ) => {
- const { data, wooSettings, ...transientState } = getState( state );
+ const { data, merchant, wooSettings, ...transientState } =
+ getState( state );
return transientState || EMPTY_OBJ;
};
+export const getActivityList = ( state ) => {
+ const { activities = new Map() } = state;
+ return Object.fromEntries( activities );
+};
+
+export const merchant = ( state ) => {
+ return getState( state ).merchant || EMPTY_OBJ;
+};
+
export const wooSettings = ( state ) => {
return getState( state ).wooSettings || EMPTY_OBJ;
};
diff --git a/modules/ppcp-settings/resources/js/data/debug.js b/modules/ppcp-settings/resources/js/data/debug.js
index b292d1920..6380c6d6a 100644
--- a/modules/ppcp-settings/resources/js/data/debug.js
+++ b/modules/ppcp-settings/resources/js/data/debug.js
@@ -1,4 +1,4 @@
-import { OnboardingStoreName } from './index';
+import { OnboardingStoreName, CommonStoreName } from './index';
export const addDebugTools = ( context, modules ) => {
if ( ! context || ! context?.debug ) {
@@ -33,9 +33,14 @@ export const addDebugTools = ( context, modules ) => {
};
context.resetStore = () => {
- const onboarding = wp.data.dispatch( OnboardingStoreName );
- onboarding.reset();
- onboarding.persist();
+ const stores = [ OnboardingStoreName, CommonStoreName ];
+
+ stores.forEach( ( storeName ) => {
+ const store = wp.data.dispatch( storeName );
+
+ store.reset();
+ store.persist();
+ } );
};
context.startOnboarding = () => {
diff --git a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js
index b0f41d450..e8582821e 100644
--- a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js
+++ b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js
@@ -34,8 +34,12 @@ const useHooks = () => {
setProducts,
} = useDispatch( STORE_NAME );
- // Read-only flags.
+ // Read-only flags and derived state.
const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] );
+ const determineProducts = useSelect(
+ ( select ) => select( STORE_NAME ).determineProducts(),
+ []
+ );
// Transient accessors.
const isReady = useTransient( 'isReady' );
@@ -80,6 +84,7 @@ const useHooks = () => {
);
return savePersistent( setProducts, validProducts );
},
+ determineProducts,
};
};
@@ -124,6 +129,12 @@ export const useNavigationState = () => {
};
};
+export const useDetermineProducts = () => {
+ const { determineProducts } = useHooks();
+
+ return determineProducts;
+};
+
export const useFlags = () => {
const { flags } = useHooks();
return flags;
diff --git a/modules/ppcp-settings/resources/js/data/onboarding/reducer.js b/modules/ppcp-settings/resources/js/data/onboarding/reducer.js
index 4ceb53f20..2b16e2416 100644
--- a/modules/ppcp-settings/resources/js/data/onboarding/reducer.js
+++ b/modules/ppcp-settings/resources/js/data/onboarding/reducer.js
@@ -12,25 +12,25 @@ import ACTION_TYPES from './action-types';
// Store structure.
-const defaultTransient = {
+const defaultTransient = Object.freeze( {
isReady: false,
// Read only values, provided by the server.
- flags: {
+ flags: Object.freeze( {
canUseCasualSelling: false,
canUseVaulting: false,
canUseCardPayments: false,
canUseSubscriptions: false,
- },
-};
+ } ),
+} );
-const defaultPersistent = {
+const defaultPersistent = Object.freeze( {
completed: false,
step: 0,
isCasualSeller: null, // null value will uncheck both options in the UI.
areOptionalPaymentMethodsEnabled: null,
products: [],
-};
+} );
// Reducer logic.
@@ -46,15 +46,28 @@ const onboardingReducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) =>
setPersistent( state, payload ),
- [ ACTION_TYPES.RESET ]: ( state ) =>
- setPersistent( state, defaultPersistent ),
+ [ ACTION_TYPES.RESET ]: ( state ) => {
+ const cleanState = setTransient(
+ setPersistent( state, defaultPersistent ),
+ defaultTransient
+ );
+
+ // Keep "read-only" details and initialization flags.
+ cleanState.flags = { ...state.flags };
+ cleanState.isReady = true;
+
+ return cleanState;
+ },
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) => {
const newState = setPersistent( state, payload.data );
// Flags are not updated by `setPersistent()`.
if ( payload.flags ) {
- newState.flags = { ...newState.flags, ...payload.flags };
+ newState.flags = Object.freeze( {
+ ...newState.flags,
+ ...payload.flags,
+ } );
}
return newState;
diff --git a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js
index d4d57ef4d..2e0953437 100644
--- a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js
+++ b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js
@@ -23,3 +23,50 @@ export const transientData = ( state ) => {
export const flags = ( state ) => {
return getState( state ).flags || EMPTY_OBJ;
};
+
+/**
+ * Returns the products that we use for the production login link in the last onboarding step.
+ *
+ * This selector does not return state-values, but uses the state to derive the products-array
+ * that should be returned.
+ *
+ * @param {{}} state
+ * @return {string[]} The ISU products, based on choices made in the onboarding wizard.
+ */
+export const determineProducts = ( state ) => {
+ const derivedProducts = [];
+
+ const { isCasualSeller, areOptionalPaymentMethodsEnabled } =
+ persistentData( state );
+ const { canUseVaulting, canUseCardPayments } = flags( state );
+
+ if ( ! canUseCardPayments || ! areOptionalPaymentMethodsEnabled ) {
+ /**
+ * Branch 1: Credit Card Payments not available.
+ * The store uses the Express-checkout product.
+ */
+ derivedProducts.push( 'EXPRESS_CHECKOUT' );
+ } else if ( isCasualSeller ) {
+ /**
+ * Branch 2: Merchant has no business.
+ * 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.
+ * The store uses the advanced PPCP product.
+ */
+ derivedProducts.push( 'PPCP' );
+ }
+
+ if ( canUseVaulting ) {
+ // TODO: Add the "Vaulting" product/feature
+ // Requirement: "... with Vault"
+ }
+
+ return derivedProducts;
+};
diff --git a/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js b/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js
new file mode 100644
index 000000000..d34e74f42
--- /dev/null
+++ b/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js
@@ -0,0 +1,214 @@
+import { __ } from '@wordpress/i18n';
+import { useDispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+
+import { CommonHooks, OnboardingHooks } from '../data';
+import { openPopup } from '../utils/window';
+
+const MESSAGES = {
+ CONNECTED: __( 'Connected to PayPal', 'woocommerce-paypal-payments' ),
+ POPUP_BLOCKED: __(
+ 'Popup blocked. Please allow popups for this site to connect to PayPal.',
+ 'woocommerce-paypal-payments'
+ ),
+ SANDBOX_ERROR: __(
+ 'Could not generate a Sandbox login link.',
+ 'woocommerce-paypal-payments'
+ ),
+ PRODUCTION_ERROR: __(
+ 'Could not generate a login link.',
+ 'woocommerce-paypal-payments'
+ ),
+ MANUAL_ERROR: __(
+ 'Could not connect to PayPal. Please make sure your Client ID and Secret Key are correct.',
+ 'woocommerce-paypal-payments'
+ ),
+ LOGIN_FAILED: __(
+ 'Login was not successful. Please try again.',
+ 'woocommerce-paypal-payments'
+ ),
+};
+
+const ACTIVITIES = {
+ CONNECT_SANDBOX: 'ISU_LOGIN_SANDBOX',
+ CONNECT_PRODUCTION: 'ISU_LOGIN_PRODUCTION',
+ CONNECT_MANUAL: 'MANUAL_LOGIN',
+};
+
+const handlePopupWithCompletion = ( url, onError ) => {
+ return new Promise( ( resolve ) => {
+ const popup = openPopup( url );
+
+ if ( ! popup ) {
+ onError( MESSAGES.POPUP_BLOCKED );
+ resolve( false );
+ return;
+ }
+
+ // Check popup state every 500ms
+ const checkPopup = setInterval( () => {
+ if ( popup.closed ) {
+ clearInterval( checkPopup );
+ resolve( true );
+ }
+ }, 500 );
+
+ return () => {
+ clearInterval( checkPopup );
+
+ if ( popup && ! popup.closed ) {
+ popup.close();
+ }
+ };
+ } );
+};
+
+const useConnectionBase = () => {
+ const { setCompleted } = OnboardingHooks.useSteps();
+ const { createSuccessNotice, createErrorNotice } =
+ useDispatch( noticesStore );
+ const { verifyLoginStatus } = CommonHooks.useMerchantInfo();
+
+ return {
+ handleFailed: ( res, genericMessage ) => {
+ console.error( 'Connection error', res );
+ createErrorNotice( res?.message ?? genericMessage );
+ },
+ handleCompleted: async () => {
+ try {
+ const loginSuccessful = await verifyLoginStatus();
+
+ if ( loginSuccessful ) {
+ createSuccessNotice( MESSAGES.CONNECTED );
+ await setCompleted( true );
+ } else {
+ createErrorNotice( MESSAGES.LOGIN_FAILED );
+ }
+ } catch ( error ) {
+ createErrorNotice( error.message ?? MESSAGES.LOGIN_FAILED );
+ }
+ },
+ createErrorNotice,
+ };
+};
+
+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
+ );
+ };
+
+ 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 = () => {
+ const { handleFailed, handleCompleted, createErrorNotice } =
+ useConnectionBase();
+ const { withActivity } = CommonHooks.useBusyState();
+ const {
+ connectViaIdAndSecret,
+ isManualConnectionMode,
+ setManualConnectionMode,
+ clientId,
+ setClientId,
+ clientSecret,
+ setClientSecret,
+ } = CommonHooks.useManualConnection();
+
+ const handleConnectViaIdAndSecret = async ( { validation } = {} ) => {
+ return withActivity(
+ ACTIVITIES.CONNECT_MANUAL,
+ 'Connecting manually via Client ID and Secret',
+ async () => {
+ if ( 'function' === typeof validation ) {
+ try {
+ validation();
+ } catch ( exception ) {
+ createErrorNotice( exception.message );
+ return;
+ }
+ }
+
+ const res = await connectViaIdAndSecret();
+
+ if ( res.success ) {
+ await handleCompleted();
+ } else {
+ handleFailed( res, MESSAGES.MANUAL_ERROR );
+ }
+
+ return res.success;
+ }
+ );
+ };
+
+ return {
+ handleConnectViaIdAndSecret,
+ isManualConnectionMode,
+ setManualConnectionMode,
+ clientId,
+ setClientId,
+ clientSecret,
+ setClientSecret,
+ };
+};
diff --git a/modules/ppcp-settings/services.php b/modules/ppcp-settings/services.php
index 31880dca0..c1eeca241 100644
--- a/modules/ppcp-settings/services.php
+++ b/modules/ppcp-settings/services.php
@@ -19,7 +19,9 @@
use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint;
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;
return array(
'settings.url' => static function ( ContainerInterface $container ) : string {
@@ -37,7 +39,8 @@
$can_use_casual_selling = $container->get( 'settings.casual-selling.eligible' );
$can_use_vaulting = $container->has( 'save-payment-methods.eligible' ) && $container->get( 'save-payment-methods.eligible' );
$can_use_card_payments = $container->has( 'card-fields.eligible' ) && $container->get( 'card-fields.eligible' );
- $can_use_subscriptions = $container->has( 'wc-subscriptions.helper' ) && $container->get( 'wc-subscriptions.helper' )->plugin_is_active();
+ $can_use_subscriptions = $container->has( 'wc-subscriptions.helper' ) && $container->get( 'wc-subscriptions.helper' )
+ ->plugin_is_active();
// Card payments are disabled for this plugin when WooPayments is active.
// TODO: Move this condition to the card-fields.eligible service?
@@ -136,9 +139,25 @@
return in_array( $country, $eligible_countries, true );
},
+ 'settings.handler.connection-listener' => static function ( ContainerInterface $container ) : ConnectionListener {
+ $page_id = $container->has( 'wcgateway.current-ppcp-settings-page-id' ) ? $container->get( 'wcgateway.current-ppcp-settings-page-id' ) : '';
+
+ return new ConnectionListener(
+ $page_id,
+ $container->get( 'settings.data.common' ),
+ $container->get( 'settings.service.onboarding-url-manager' ),
+ $container->get( 'woocommerce.logger.woocommerce' )
+ );
+ },
'settings.service.signup-link-cache' => static function ( ContainerInterface $container ) : Cache {
return new Cache( 'ppcp-paypal-signup-link' );
},
+ 'settings.service.onboarding-url-manager' => static function ( ContainerInterface $container ) : OnboardingUrlManager {
+ return new OnboardingUrlManager(
+ $container->get( 'settings.service.signup-link-cache' ),
+ $container->get( 'woocommerce.logger.woocommerce' )
+ );
+ },
'settings.service.connection-url-generators' => static function ( ContainerInterface $container ) : array {
// Define available environments.
$environments = array(
@@ -157,8 +176,8 @@
$generators[ $environment ] = new ConnectionUrlGenerator(
$config['partner_referrals'],
$container->get( 'api.repository.partner-referrals-data' ),
- $container->get( 'settings.service.signup-link-cache' ),
$environment,
+ $container->get( 'settings.service.onboarding-url-manager' ),
$container->get( 'woocommerce.logger.woocommerce' )
);
}
diff --git a/modules/ppcp-settings/src/Data/CommonSettings.php b/modules/ppcp-settings/src/Data/CommonSettings.php
index b377b66aa..1894255ff 100644
--- a/modules/ppcp-settings/src/Data/CommonSettings.php
+++ b/modules/ppcp-settings/src/Data/CommonSettings.php
@@ -59,6 +59,12 @@ protected function get_defaults() : array {
'use_manual_connection' => false,
'client_id' => '',
'client_secret' => '',
+
+ // Details about connected merchant account.
+ 'merchant_connected' => false,
+ 'sandbox_merchant' => false,
+ 'merchant_id' => '',
+ 'merchant_email' => '',
);
}
@@ -144,4 +150,58 @@ public function set_client_secret( string $client_secret ) : void {
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/Endpoint/CommonRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php
index 721c07e11..3c0131759 100644
--- a/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php
+++ b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php
@@ -61,7 +61,27 @@ class CommonRestEndpoint extends RestEndpoint {
);
/**
- * Map the internal flags to JS names.
+ * Map merchant details to JS names.
+ *
+ * @var array
+ */
+ private array $merchant_info_map = array(
+ 'merchant_connected' => array(
+ 'js_name' => 'isConnected',
+ ),
+ 'sandbox_merchant' => array(
+ 'js_name' => 'isSandbox',
+ ),
+ 'merchant_id' => array(
+ 'js_name' => 'id',
+ ),
+ 'merchant_email' => array(
+ 'js_name' => 'email',
+ ),
+ );
+
+ /**
+ * Map woo-settings to JS names.
*
* @var array
*/
@@ -110,6 +130,18 @@ public function register_routes() {
),
)
);
+
+ 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' ),
+ ),
+ )
+ );
}
/**
@@ -123,17 +155,10 @@ public function get_details() : WP_REST_Response {
$this->field_map
);
- $js_woo_settings = $this->sanitize_for_javascript(
- $this->settings->get_woo_settings(),
- $this->woo_settings_map
- );
+ $extra_data = $this->add_woo_settings( array() );
+ $extra_data = $this->add_merchant_info( $extra_data );
- return $this->return_success(
- $js_data,
- array(
- 'wooSettings' => $js_woo_settings,
- )
- );
+ return $this->return_success( $js_data, $extra_data );
}
/**
@@ -154,4 +179,50 @@ public function update_details( WP_REST_Request $request ) : WP_REST_Response {
return $this->get_details();
}
+
+ /**
+ * Returns only the (read-only) merchant details from the DB.
+ *
+ * @return WP_REST_Response Merchant details.
+ */
+ public function get_merchant_details() : WP_REST_Response {
+ $js_data = array(); // No persistent data.
+ $extra_data = $this->add_merchant_info( array() );
+
+ return $this->return_success( $js_data, $extra_data );
+ }
+
+ /**
+ * Appends the "merchant" attribute to the extra_data collection, which
+ * contains details about the merchant's PayPal account, like the merchant ID.
+ *
+ * @param array $extra_data Initial extra_data collection.
+ *
+ * @return array Updated extra_data collection.
+ */
+ protected function add_merchant_info( array $extra_data ) : array {
+ $extra_data['merchant'] = $this->sanitize_for_javascript(
+ $this->settings->to_array(),
+ $this->merchant_info_map
+ );
+
+ return $extra_data;
+ }
+
+ /**
+ * Appends the "wooSettings" attribute to the extra_data collection to
+ * provide WooCommerce store details, like the store country and currency.
+ *
+ * @param array $extra_data Initial extra_data collection.
+ *
+ * @return array Updated extra_data collection.
+ */
+ protected function add_woo_settings( array $extra_data ) : array {
+ $extra_data['wooSettings'] = $this->sanitize_for_javascript(
+ $this->settings->get_woo_settings(),
+ $this->woo_settings_map
+ );
+
+ return $extra_data;
+ }
}
diff --git a/modules/ppcp-settings/src/Handler/ConnectionListener.php b/modules/ppcp-settings/src/Handler/ConnectionListener.php
new file mode 100644
index 000000000..a24a82231
--- /dev/null
+++ b/modules/ppcp-settings/src/Handler/ConnectionListener.php
@@ -0,0 +1,241 @@
+settings_page_id = $settings_page_id;
+ $this->settings = $settings;
+ $this->url_manager = $url_manager;
+ $this->logger = $logger ?: new NullLogger();
+
+ // Initialize as "guest", the real ID is provided via process().
+ $this->user_id = 0;
+ }
+
+ /**
+ * Process the request data, and extract connection details, if present.
+ *
+ * @param int $user_id The current user ID.
+ * @param array $request Request details to process.
+ */
+ public function process( int $user_id, array $request ) : void {
+ $this->user_id = $user_id;
+
+ if ( ! $this->is_valid_request( $request ) ) {
+ return;
+ }
+
+ $token = $this->get_token_from_request( $request );
+ if ( ! $this->url_manager->validate_token_and_delete( $token, $this->user_id ) ) {
+ return;
+ }
+
+ $data = $this->extract_data( $request );
+ if ( ! $data ) {
+ return;
+ }
+
+ $this->logger->info( 'Found merchant data in request', $data );
+
+ $this->store_data(
+ $data['is_sandbox'],
+ $data['merchant_id'],
+ $data['merchant_email']
+ );
+ }
+
+ /**
+ * Determine, if the request details contain connection data that should be
+ * extracted and stored.
+ *
+ * @param array $request Request details to verify.
+ *
+ * @return bool True, if the request contains valid connection details.
+ */
+ protected function is_valid_request( array $request ) : bool {
+ if ( $this->user_id < 1 || ! $this->settings_page_id ) {
+ return false;
+ }
+
+ if ( ! user_can( $this->user_id, 'manage_woocommerce' ) ) {
+ return false;
+ }
+
+ $required_params = array(
+ 'merchantIdInPayPal',
+ 'merchantId',
+ 'ppcpToken',
+ );
+
+ foreach ( $required_params as $param ) {
+ if ( empty( $request[ $param ] ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Extract the merchant details (ID & email) from the request details.
+ *
+ * @param array $request The full request details.
+ *
+ * @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 {
+ $this->logger->info( 'Extracting connection data from request...' );
+
+ $merchant_id = $this->get_merchant_id_from_request( $request );
+ $merchant_email = $this->get_merchant_email_from_request( $request );
+
+ if ( ! $merchant_id || ! $merchant_email ) {
+ return array();
+ }
+
+ return array(
+ 'is_sandbox' => $this->settings->get_sandbox(),
+ 'merchant_id' => $merchant_id,
+ 'merchant_email' => $merchant_email,
+ );
+ }
+
+ /**
+ * Persist the merchant details to the database.
+ *
+ * @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.
+ */
+ 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)" );
+
+ $this->settings->set_merchant_data( $is_sandbox, $merchant_id, $merchant_email );
+ $this->settings->save();
+ }
+
+ /**
+ * Returns the sanitized connection token from the incoming request.
+ *
+ * @param array $request Full request details.
+ *
+ * @return string The sanitized token, or an empty string.
+ */
+ protected function get_token_from_request( array $request ) : string {
+ return $this->sanitize_string( $request['ppcpToken'] ?? '' );
+ }
+
+ /**
+ * Returns the sanitized merchant ID from the incoming request.
+ *
+ * @param array $request Full request details.
+ *
+ * @return string The sanitized merchant ID, or an empty string.
+ */
+ protected function get_merchant_id_from_request( array $request ) : string {
+ return $this->sanitize_string( $request['merchantIdInPayPal'] ?? '' );
+ }
+
+ /**
+ * Returns the sanitized merchant email from the incoming request.
+ *
+ * Note that the email is provided via the argument "merchantId", which
+ * looks incorrect at first, but PayPal uses the email address as merchant
+ * IDm and offers a more anonymous ID via the "merchantIdInPayPal" argument.
+ *
+ * @param array $request Full request details.
+ *
+ * @return string The sanitized merchant email, or an empty string.
+ */
+ protected function get_merchant_email_from_request( array $request ) : string {
+ return $this->sanitize_merchant_email( $request['merchantId'] ?? '' );
+ }
+
+ /**
+ * Sanitizes a request-argument for processing.
+ *
+ * @param string $value Value from the request argument.
+ *
+ * @return string Sanitized value.
+ */
+ protected function sanitize_string( string $value ) : string {
+ return trim( sanitize_text_field( wp_unslash( $value ) ) );
+ }
+
+ /**
+ * Sanitizes the merchant's email address for processing.
+ *
+ * @param string $email The plain email.
+ *
+ * @return string Sanitized email address.
+ */
+ protected function sanitize_merchant_email( string $email ) : string {
+ return sanitize_text_field( str_replace( ' ', '+', $email ) );
+ }
+}
diff --git a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php
index 6e91aba3a..028740cb9 100644
--- a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php
+++ b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php
@@ -14,9 +14,11 @@
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData;
-use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl;
use WooCommerce\WooCommerce\Logging\Logger\NullLogger;
+// TODO: Replace the OnboardingUrl with a new implementation for this module.
+use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl;
+
/**
* Generator that builds the ISU connection URL.
*/
@@ -36,11 +38,11 @@ class ConnectionUrlGenerator {
protected PartnerReferralsData $referrals_data;
/**
- * The cache
+ * Manages access to OnboardingUrl instances
*
- * @var Cache
+ * @var OnboardingUrlManager
*/
- protected Cache $cache;
+ protected OnboardingUrlManager $url_manager;
/**
* Which environment is used for the connection URL.
@@ -54,7 +56,7 @@ class ConnectionUrlGenerator {
*
* @var LoggerInterface
*/
- private $logger;
+ private LoggerInterface $logger;
/**
* Constructor for the ConnectionUrlGenerator class.
@@ -63,23 +65,22 @@ class ConnectionUrlGenerator {
*
* @param PartnerReferrals $partner_referrals PartnerReferrals for URL generation.
* @param PartnerReferralsData $referrals_data Default partner referrals data.
- * @param Cache $cache The cache object used for storing and
- * retrieving data.
* @param string $environment Environment that is used to generate the URL.
* ['production'|'sandbox'].
+ * @param OnboardingUrlManager $url_manager Manages access to OnboardingUrl instances.
* @param ?LoggerInterface $logger The logger object for logging messages.
*/
public function __construct(
PartnerReferrals $partner_referrals,
PartnerReferralsData $referrals_data,
- Cache $cache,
string $environment,
+ OnboardingUrlManager $url_manager,
?LoggerInterface $logger = null
) {
$this->partner_referrals = $partner_referrals;
$this->referrals_data = $referrals_data;
- $this->cache = $cache;
$this->environment = $environment;
+ $this->url_manager = $url_manager;
$this->logger = $logger ?: new NullLogger();
}
@@ -107,7 +108,7 @@ public function environment() : string {
public function generate( array $products = array() ) : string {
$cache_key = $this->cache_key( $products );
$user_id = get_current_user_id();
- $onboarding_url = new OnboardingUrl( $this->cache, $cache_key, $user_id );
+ $onboarding_url = $this->url_manager->get( $cache_key, $user_id );
$cached_url = $this->try_get_from_cache( $onboarding_url, $cache_key );
if ( $cached_url ) {
diff --git a/modules/ppcp-settings/src/Service/OnboardingUrlManager.php b/modules/ppcp-settings/src/Service/OnboardingUrlManager.php
new file mode 100644
index 000000000..f2463af46
--- /dev/null
+++ b/modules/ppcp-settings/src/Service/OnboardingUrlManager.php
@@ -0,0 +1,101 @@
+cache = $cache;
+ $this->logger = $logger ?: new NullLogger();
+ }
+
+ /**
+ * Returns a new Onboarding Url instance.
+ *
+ * @param string $cache_key_prefix The prefix for the cache entry.
+ * @param int $user_id User ID to associate the link with.
+ *
+ * @return OnboardingUrl
+ */
+ public function get( string $cache_key_prefix, int $user_id ) : OnboardingUrl {
+ return new OnboardingUrl( $this->cache, $cache_key_prefix, $user_id );
+ }
+
+ /**
+ * Validates the authentication token; if it's valid, the token is instantly
+ * invalidated (deleted), so it cannot be validated again.
+ *
+ * @param string $token The token to validate.
+ * @param int $user_id User ID who generated the token.
+ *
+ * @return bool True, if the token is valid. False otherwise.
+ */
+ public function validate_token_and_delete( string $token, int $user_id ) : bool {
+ if ( $user_id < 1 || strlen( $token ) < 10 ) {
+ return false;
+ }
+
+ $log_token = ( (string) substr( $token, 0, 2 ) ) . '...' . ( (string) substr( $token, - 6 ) );
+ $this->logger->debug( 'Validating onboarding ppcpToken: ' . $log_token );
+
+ if ( OnboardingUrl::validate_token_and_delete( $this->cache, $token, $user_id ) ) {
+ $this->logger->info( 'Validated onboarding ppcpToken: ' . $log_token );
+
+ return true;
+ }
+
+ if ( OnboardingUrl::validate_previous_token( $this->cache, $token, $user_id ) ) {
+ // TODO: Do we need this here? Previous logic was to reload the page without doing anything in this case.
+ $this->logger->info( 'Validated previous token, silently redirecting: ' . $log_token );
+
+ return true;
+ }
+
+ $this->logger->error( 'Failed to validate onboarding ppcpToken: ' . $log_token );
+
+ return false;
+ }
+}
diff --git a/modules/ppcp-settings/src/SettingsModule.php b/modules/ppcp-settings/src/SettingsModule.php
index 7c9dca2f8..7cb55bb02 100644
--- a/modules/ppcp-settings/src/SettingsModule.php
+++ b/modules/ppcp-settings/src/SettingsModule.php
@@ -11,6 +11,7 @@
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;
@@ -85,7 +86,7 @@ static function () use ( $container ) {
}
);
- $endpoint = $container->get( 'settings.switch-ui.endpoint' );
+ $endpoint = $container->get( 'settings.switch-ui.endpoint' ) ? $container->get( 'settings.switch-ui.endpoint' ) : null;
assert( $endpoint instanceof SwitchSettingsUiEndpoint );
add_action(
@@ -189,6 +190,17 @@ static function () use ( $container ) : void {
}
);
+ add_action(
+ 'admin_init',
+ static function () use ( $container ) : void {
+ $connection_handler = $container->get( 'settings.handler.connection-listener' );
+ assert( $connection_handler instanceof ConnectionListener );
+
+ // @phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no nonce; sanitation done by the handler
+ $connection_handler->process( get_current_user_id(), $_GET );
+ }
+ );
+
return true;
}
diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php
index 9d57421ae..b215e3804 100644
--- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php
+++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php
@@ -653,7 +653,10 @@ static function () use ( $container ) {
$listener = $container->get( 'wcgateway.settings.listener' );
assert( $listener instanceof SettingsListener );
- $listener->listen_for_merchant_id();
+ $use_new_ui = $container->get( 'wcgateway.settings.admin-settings-enabled' );
+ if ( ! $use_new_ui ) {
+ $listener->listen_for_merchant_id();
+ }
try {
$listener->listen_for_vaulting_enabled();