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();