🔀 Merge branch 'temp/settings-ui'

# Conflicts:
#	modules/ppcp-settings/resources/js/Components/Screens/Settings.js
This commit is contained in:
Philipp Stracker 2024-12-11 17:05:28 +01:00
commit c99793ad4b
No known key found for this signature in database
48 changed files with 1631 additions and 381 deletions

View file

@ -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};
}

View file

@ -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)
}
}

View file

@ -0,0 +1,10 @@
.ppcp-r-busy-wrapper {
position: relative;
&.ppcp--is-loading {
pointer-events: none;
user-select: none;
--spinner-overlay-color: #fff4;
}
}

View file

@ -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);
border-radius: 50px;
padding: 15px 32px;
height: auto;
--button-disabled-color: var(--color-gray-500);
--button-disabled-background: transparent;
--button-disabled-border-color: transparent;
/* style the button template */
&: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;
}
}

View file

@ -31,10 +31,4 @@
&__toggled-content {
margin-top: 24px;
}
&.ppcp--is-loading {
pointer-events: none;
--spinner-overlay-color: #fff4;
}
}

View file

@ -12,5 +12,6 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
}
}

View file

@ -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;

View file

@ -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';

View file

@ -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 (
<BusyContext.Provider value={ isBusyComponent }>
<div className={ wrapperClassName }>
{ showSpinner && <SpinnerOverlay /> }
{ memoizedChildren }
</div>
</BusyContext.Provider>
);
};
export default BusyStateWrapper;

View file

@ -0,0 +1 @@
export { default as openSignup } from './Icons/open-signup';

View file

@ -0,0 +1,12 @@
/**
* WordPress dependencies
*/
import { SVG, Path } from '@wordpress/primitives';
const openSignup = (
<SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 24">
<Path d="M12.4999 12.75V18.75C12.4999 18.9489 12.4209 19.1397 12.2803 19.2803C12.1396 19.421 11.9488 19.5 11.7499 19.5C11.551 19.5 11.3603 19.421 11.2196 19.2803C11.0789 19.1397 10.9999 18.9489 10.9999 18.75V14.5613L4.78055 20.7806C4.71087 20.8503 4.62815 20.9056 4.5371 20.9433C4.44606 20.981 4.34847 21.0004 4.24993 21.0004C4.15138 21.0004 4.0538 20.981 3.96276 20.9433C3.87171 20.9056 3.78899 20.8503 3.7193 20.7806C3.64962 20.7109 3.59435 20.6282 3.55663 20.5372C3.51892 20.4461 3.49951 20.3485 3.49951 20.25C3.49951 20.1515 3.51892 20.0539 3.55663 19.9628C3.59435 19.8718 3.64962 19.7891 3.7193 19.7194L9.93868 13.5H5.74993C5.55102 13.5 5.36025 13.421 5.2196 13.2803C5.07895 13.1397 4.99993 12.9489 4.99993 12.75C4.99993 12.5511 5.07895 12.3603 5.2196 12.2197C5.36025 12.079 5.55102 12 5.74993 12H11.7499C11.9488 12 12.1396 12.079 12.2803 12.2197C12.4209 12.3603 12.4999 12.5511 12.4999 12.75ZM19.9999 3H7.99993C7.6021 3 7.22057 3.15804 6.93927 3.43934C6.65796 3.72064 6.49993 4.10218 6.49993 4.5V9C6.49993 9.19891 6.57895 9.38968 6.7196 9.53033C6.86025 9.67098 7.05102 9.75 7.24993 9.75C7.44884 9.75 7.63961 9.67098 7.78026 9.53033C7.92091 9.38968 7.99993 9.19891 7.99993 9V4.5H19.9999V16.5H15.4999C15.301 16.5 15.1103 16.579 14.9696 16.7197C14.8289 16.8603 14.7499 17.0511 14.7499 17.25C14.7499 17.4489 14.8289 17.6397 14.9696 17.7803C15.1103 17.921 15.301 18 15.4999 18H19.9999C20.3978 18 20.7793 17.842 21.0606 17.5607C21.3419 17.2794 21.4999 16.8978 21.4999 16.5V4.5C21.4999 4.10218 21.3419 3.72064 21.0606 3.43934C20.7793 3.15804 20.3978 3 19.9999 3Z" />
</SVG>
);
export default openSignup;

View file

@ -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',

View file

@ -11,7 +11,6 @@ const PaymentMethodIcons = ( props ) => {
<PaymentMethodIcon type="discover" icons={ props.icons } />
<PaymentMethodIcon type="apple-pay" icons={ props.icons } />
<PaymentMethodIcon type="google-pay" icons={ props.icons } />
<PaymentMethodIcon type="sepa" icons={ props.icons } />
<PaymentMethodIcon type="ideal" icons={ props.icons } />
<PaymentMethodIcon type="bancontact" icons={ props.icons } />
</div>

View file

@ -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 }
/>
</div>
</div>
{ props.children && isToggled && (
<div className="ppcp-r-toggle-block__toggled-content">
{ isLoading && <SpinnerOverlay /> }
{ props.children }
</div>
) }

View file

@ -1,8 +1,13 @@
import { Spinner } from '@wordpress/components';
const SpinnerOverlay = () => {
const SpinnerOverlay = ( { message = '' } ) => {
return (
<div className="ppcp-r-spinner-overlay">
{ message && (
<span className="ppcp-r-spinner-overlay__message">
{ message }
</span>
) }
<Spinner />
</div>
);

View file

@ -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. <a target="_blank" href="%s">Learn more</a>',
'Offer installment payment options and get paid upfront. <a target="_blank" href="%s">Learn more</a>',
'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. <a target="_blank" href="%s">Learn more</a>',
'Offer installment payment options and get paid upfront. <a target="_blank" href="%s">Learn more</a>',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '

View file

@ -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. <a target="_blank" href="%s">Learn more</a>',
'Offer installment payment options and get paid upfront. <a target="_blank" href="%s">Learn more</a>',
'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. <a target="_blank" href="%s">Learn more</a>',
'Offer installment payment options and get paid upfront. <a target="_blank" href="%s">Learn more</a>',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '

View file

@ -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
__(
'<sup>1</sup>Prices based on domestic transactions as of October 25th, 2024. <a target="_blank" href="%s">Click here</a> for full pricing details.',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
);
return (
<div className="ppcp-r-welcome-docs">
<h2 className="ppcp-r-welcome-docs__title">
@ -41,10 +33,14 @@ const WelcomeDocs = ( {
storeCurrency={ storeCurrency }
/>
) }
<p
className="ppcp-r-optional-payment-methods__description"
dangerouslySetInnerHTML={ { __html: pricesBasedDescription } }
></p>
{ storeCountry in countryPriceInfo && (
<p
className="ppcp-r-optional-payment-methods__description"
dangerouslySetInnerHTML={ {
__html: pricesBasedDescription,
} }
></p>
) }
</div>
);
};

View file

@ -0,0 +1,10 @@
import { __, sprintf } from '@wordpress/i18n';
export const pricesBasedDescription = sprintf(
// translators: %s: Link to PayPal REST application guide
__(
'<sup>1</sup>Prices based on domestic transactions as of October 25th, 2024. <a target="_blank" href="%s">Click here</a> for full pricing details.',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
);

View file

@ -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 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,
},
];
const isFormValid = useMemo( () => {
return isValidClientId && clientId && clientSecret;
}, [ isValidClientId, clientId, clientSecret ] );
for ( const { ref, valid, errorMessage } of checks ) {
if ( valid() ) {
continue;
}
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;
ref?.current?.focus();
throw new Error( errorMessage );
}
}, [ clientId, clientSecret, clientValid, secretValid ] );
const connectionUrl = res.data;
const popup = openPopup( connectionUrl );
const handleManualConnect = useCallback(
() =>
handleConnectViaIdAndSecret( {
validation: validateManualConnectionForm,
} ),
[ handleConnectViaIdAndSecret, validateManualConnectionForm ]
);
if ( ! popup ) {
createErrorNotice(
__(
'Popup blocked. Please allow popups for this site to connect to PayPal.',
'woocommerce-paypal-payments'
)
);
}
};
useEffect( () => {
setClientValid( ! clientId || /^A[\w-]{79}$/.test( clientId ) );
setSecretValid( clientSecret && clientSecret.length > 0 );
}, [ clientId, clientSecret ] );
const handleManualConnect = async () => {
const res = await connectViaIdAndSecret();
const clientIdLabel = useMemo(
() =>
isSandboxMode
? __( 'Sandbox Client ID', 'woocommerce-paypal-payments' )
: __( 'Live Client ID', 'woocommerce-paypal-payments' ),
[ isSandboxMode ]
);
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 (
<>
<SettingsToggleBlock
label={ __(
'Enable Sandbox Mode',
'woocommerce-paypal-payments'
) }
description={ __(
'Activate Sandbox mode to safely test PayPal with sample data. Once your store is ready to go live, you can easily switch to your production account.',
'woocommerce-paypal-payments'
) }
isToggled={ !! isSandboxMode }
setToggled={ setSandboxMode }
isLoading={ isBusy }
>
<Button onClick={ handleSandboxConnect } variant="secondary">
{ __( 'Connect Account', 'woocommerce-paypal-payments' ) }
</Button>
</SettingsToggleBlock>
<Separator withLine={ false } />
<SettingsToggleBlock
label={
__( 'Manually Connect', 'woocommerce-paypal-payments' ) +
( isBusy ? ' ...' : '' )
}
description={ advancedUsersDescription }
isToggled={ !! isManualConnectionMode }
setToggled={ setManualConnectionMode }
isLoading={ isBusy }
>
<DataStoreControl
control={ TextControl }
ref={ refClientId }
label={
isSandboxMode
? __(
'Sandbox Client ID',
'woocommerce-paypal-payments'
)
: __(
'Live Client ID',
'woocommerce-paypal-payments'
)
}
value={ clientId }
onChange={ setClientId }
className={
clientId && ! isValidClientId ? 'has-error' : ''
}
/>
{ clientId && ! isValidClientId && (
<p className="client-id-error">
{ __(
'Please enter a valid Client ID',
<BusyStateWrapper>
<SettingsToggleBlock
label={ __(
'Enable Sandbox Mode',
'woocommerce-paypal-payments'
) }
description={ __(
'Activate Sandbox mode to safely test PayPal with sample data. Once your store is ready to go live, you can easily switch to your production account.',
'woocommerce-paypal-payments'
) }
isToggled={ !! isSandboxMode }
setToggled={ setSandboxMode }
>
<ConnectionButton
title={ __(
'Connect Account',
'woocommerce-paypal-payments'
) }
</p>
) }
<DataStoreControl
control={ TextControl }
ref={ refClientSecret }
label={
isSandboxMode
? __(
'Sandbox Secret Key',
'woocommerce-paypal-payments'
)
: __(
'Live Secret Key',
'woocommerce-paypal-payments'
)
}
value={ clientSecret }
onChange={ setClientSecret }
type="password"
/>
<Button
variant="secondary"
onClick={ handleManualConnect }
disabled={ ! isFormValid }
showIcon={ false }
variant="secondary"
className="small-button"
isSandbox={
true /* This button always connects to sandbox */
}
/>
</SettingsToggleBlock>
</BusyStateWrapper>
<Separator withLine={ false } />
<BusyStateWrapper
onBusy={ ( props ) => ( {
disabled: true,
label: props.label + ' ...',
} ) }
>
<SettingsToggleBlock
label={ __(
'Manually Connect',
'woocommerce-paypal-payments'
) }
description={ advancedUsersDescription }
isToggled={ !! isManualConnectionMode }
setToggled={ setManualConnectionMode }
>
{ __( 'Connect Account', 'woocommerce-paypal-payments' ) }
</Button>
</SettingsToggleBlock>
<DataStoreControl
control={ TextControl }
ref={ refClientId }
label={ clientIdLabel }
value={ clientId }
onChange={ setClientId }
className={ classNames( {
'has-error': ! clientValid,
} ) }
/>
{ clientValid || (
<p className="client-id-error">
{ FORM_ERRORS.invalidClientId }
</p>
) }
<DataStoreControl
control={ TextControl }
ref={ refClientSecret }
label={ secretKeyLabel }
value={ clientSecret }
onChange={ setClientSecret }
type="password"
/>
<Button
variant="secondary"
className="small-button"
onClick={ handleManualConnect }
>
{ __(
'Connect Account',
'woocommerce-paypal-payments'
) }
</Button>
</SettingsToggleBlock>
</BusyStateWrapper>
</>
);
};

View file

@ -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 (
<BusyStateWrapper>
<Button
className={ buttonClassName }
variant={ variant }
icon={ showIcon ? openSignup : null }
onClick={ handleConnectClick }
>
<span className="button-title">{ title }</span>
</Button>
</BusyStateWrapper>
);
};
export default ConnectionButton;

View file

@ -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 (
<div className={ className }>
<div className="ppcp-r-navigation">
<div className="ppcp-r-navigation--left">
<BusyStateWrapper
className="ppcp-r-navigation--left"
busySpinner={ false }
enabled={ ! isFirst }
>
<Button
variant="link"
onClick={ isFirst ? onExit : onPrev }
@ -31,7 +36,7 @@ const Navigation = ( { stepDetails, onNext, onPrev, onExit } ) => {
{ title }
</span>
</Button>
</div>
</BusyStateWrapper>
{ ! isFirst &&
NextButton( { showNext, isDisabled, onNext, onExit } ) }
<ProgressBar percent={ percentage } />
@ -42,7 +47,10 @@ const Navigation = ( { stepDetails, onNext, onPrev, onExit } ) => {
const NextButton = ( { showNext, isDisabled, onNext, onExit } ) => {
return (
<div className="ppcp-r-navigation--right">
<BusyStateWrapper
className="ppcp-r-navigation--right"
busySpinner={ false }
>
<Button variant="link" onClick={ onExit }>
{ __( 'Save and exit', 'woocommerce-paypal-payments' ) }
</Button>
@ -55,7 +63,7 @@ const NextButton = ( { showNext, isDisabled, onNext, onExit } ) => {
{ __( 'Continue', 'woocommerce-paypal-payments' ) }
</Button>
) }
</div>
</BusyStateWrapper>
);
};

View file

@ -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 = () => {
<currentStep.StepComponent
setStep={ setStep }
currentStep={ step }
setCompleted={ setCompleted }
stepperOrder={ Steps }
/>
</div>

View file

@ -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 = () => (
<Icon
icon={ () => (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.4999 12.75V18.75C12.4999 18.9489 12.4209 19.1397 12.2803 19.2803C12.1396 19.421 11.9488 19.5 11.7499 19.5C11.551 19.5 11.3603 19.421 11.2196 19.2803C11.0789 19.1397 10.9999 18.9489 10.9999 18.75V14.5613L4.78055 20.7806C4.71087 20.8503 4.62815 20.9056 4.5371 20.9433C4.44606 20.981 4.34847 21.0004 4.24993 21.0004C4.15138 21.0004 4.0538 20.981 3.96276 20.9433C3.87171 20.9056 3.78899 20.8503 3.7193 20.7806C3.64962 20.7109 3.59435 20.6282 3.55663 20.5372C3.51892 20.4461 3.49951 20.3485 3.49951 20.25C3.49951 20.1515 3.51892 20.0539 3.55663 19.9628C3.59435 19.8718 3.64962 19.7891 3.7193 19.7194L9.93868 13.5H5.74993C5.55102 13.5 5.36025 13.421 5.2196 13.2803C5.07895 13.1397 4.99993 12.9489 4.99993 12.75C4.99993 12.5511 5.07895 12.3603 5.2196 12.2197C5.36025 12.079 5.55102 12 5.74993 12H11.7499C11.9488 12 12.1396 12.079 12.2803 12.2197C12.4209 12.3603 12.4999 12.5511 12.4999 12.75ZM19.9999 3H7.99993C7.6021 3 7.22057 3.15804 6.93927 3.43934C6.65796 3.72064 6.49993 4.10218 6.49993 4.5V9C6.49993 9.19891 6.57895 9.38968 6.7196 9.53033C6.86025 9.67098 7.05102 9.75 7.24993 9.75C7.44884 9.75 7.63961 9.67098 7.78026 9.53033C7.92091 9.38968 7.99993 9.19891 7.99993 9V4.5H19.9999V16.5H15.4999C15.301 16.5 15.1103 16.579 14.9696 16.7197C14.8289 16.8603 14.7499 17.0511 14.7499 17.25C14.7499 17.4489 14.8289 17.6397 14.9696 17.7803C15.1103 17.921 15.301 18 15.4999 18H19.9999C20.3978 18 20.7793 17.842 21.0606 17.5607C21.3419 17.2794 21.4999 16.8978 21.4999 16.5V4.5C21.4999 4.10218 21.3419 3.72064 21.0606 3.43934C20.7793 3.15804 20.3978 3 19.9999 3Z"
fill="white"
/>
</svg>
) }
/>
);
const StepCompleteSetup = () => {
return (
<div className="ppcp-r-page-products">
<OnboardingHeader
@ -37,18 +18,12 @@ const StepCompleteSetup = ( { setCompleted } ) => {
/>
<div className="ppcp-r-inner-container">
<div className="ppcp-r-onboarding-header__description">
<Button
variant="primary"
icon={ ButtonIcon }
onClick={ () => {
setCompleted( true );
} }
>
{ __(
<ConnectionButton
title={ __(
'Connect to PayPal',
'woocommerce-paypal-payments'
) }
</Button>
/>
</div>
</div>
</div>

View file

@ -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
__(
'<sup>1</sup>Prices based on domestic transactions as of October 25th, 2024. <a target="_blank" href="%s">Click here</a> for full pricing details.',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input '
);
return (
<div className="ppcp-r-page-optional-payment-methods">
<OnboardingHeader
@ -67,12 +60,14 @@ const StepPaymentMethods = ( {} ) => {
type="radio"
></SelectBox>
</SelectBoxWrapper>
<p
className="ppcp-r-optional-payment-methods__description"
dangerouslySetInnerHTML={ {
__html: pricesBasedDescription,
} }
></p>
{ storeCountry in countryPriceInfo && (
<p
className="ppcp-r-optional-payment-methods__description"
dangerouslySetInnerHTML={ {
__html: pricesBasedDescription,
} }
></p>
) }
</div>
</div>
);

View file

@ -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 (
<div className="ppcp-r-page-welcome">
<OnboardingHeader
@ -33,16 +35,18 @@ const StepWelcome = ( { setStep, currentStep, setCompleted } ) => {
'woocommerce-paypal-payments'
) }
</p>
<Button
className="ppcp-r-button-activate-paypal"
variant="primary"
onClick={ () => setStep( currentStep + 1 ) }
>
{ __(
'Activate PayPal Payments',
'woocommerce-paypal-payments'
) }
</Button>
<BusyStateWrapper>
<Button
className="ppcp-r-button-activate-paypal"
variant="primary"
onClick={ () => setStep( currentStep + 1 ) }
>
{ __(
'Activate PayPal Payments',
'woocommerce-paypal-payments'
) }
</Button>
</BusyStateWrapper>
</div>
<Separator className="ppcp-r-page-welcome-mode-separator" />
<WelcomeDocs
@ -61,7 +65,7 @@ const StepWelcome = ( { setStep, currentStep, setCompleted } ) => {
className="onboarding-advanced-options"
id="advanced-options"
>
<AdvancedOptionsForm setCompleted={ setCompleted } />
<AdvancedOptionsForm />
</AccordionSection>
</div>
);

View file

@ -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 <div>Loading...</div>;
}
const wrapperClass = classNames( 'ppcp-r-app', {
loading: ! onboardingProgress.isReady,
} );
if ( ! onboardingProgress.completed ) {
return <Onboarding />;
}
const Content = useMemo( () => {
if ( ! onboardingProgress.isReady ) {
return (
<SpinnerOverlay
message={ __( 'Loading…', 'woocommerce-paypal-payments' ) }
/>
);
}
return <SettingsScreen />;
if ( ! onboardingProgress.completed ) {
return <Onboarding />;
}
return <SettingsScreen />;
}, [ onboardingProgress ] );
return <div className={ wrapperClass }>{ Content }</div>;
};
export default Settings;

View file

@ -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',
};

View file

@ -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 {boolean} isBusy
* @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 {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 );
export const connectToSandbox = function* () {
return yield { type: ACTION_TYPES.DO_SANDBOX_LOGIN };
};
const result = yield { type: ACTION_TYPES.DO_SANDBOX_LOGIN };
yield setIsBusy( false );
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 };
};

View file

@ -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';

View file

@ -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;
},
};

View file

@ -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.
};
};

View file

@ -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;
},

View file

@ -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;
};

View file

@ -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 = () => {

View file

@ -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;

View file

@ -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;

View file

@ -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;
};

View file

@ -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,
};
};

View file

@ -19,7 +19,9 @@ use WooCommerce\PayPalCommerce\Settings\Endpoint\LoginLinkRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator;
use WooCommerce\PayPalCommerce\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 @@ return array(
$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 array(
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 @@ return array(
$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' )
);
}

View file

@ -59,6 +59,12 @@ class CommonSettings extends AbstractDataModel {
'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 @@ class CommonSettings extends AbstractDataModel {
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'];
}
}

View file

@ -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 @@ class CommonRestEndpoint extends RestEndpoint {
),
)
);
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 @@ class CommonRestEndpoint extends RestEndpoint {
$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 @@ class CommonRestEndpoint extends RestEndpoint {
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;
}
}

View file

@ -0,0 +1,241 @@
<?php
/**
* Handles connection-requests, that connect the current site to a PayPal
* merchant account.
*
* @package WooCommerce\PayPalCommerce\Settings\Handler
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Handler;
use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings;
use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager;
use WooCommerce\WooCommerce\Logging\Logger\NullLogger;
use Psr\Log\LoggerInterface;
/**
* Provides a listener that handles merchant-connection requests.
*
* Those connection requests are made after the merchant logs into their PayPal
* account (inside the login popup). At the last step, they see a "Return to
* Store" button.
* Clicking that button triggers the merchant-connection request.
*/
class ConnectionListener {
/**
* ID of the current settings page; empty if not on a PayPal settings page.
*
* @var string
*/
private string $settings_page_id;
/**
* Access to connection settings.
*
* @var CommonSettings
*/
private CommonSettings $settings;
/**
* Access to the onboarding URL manager.
*
* @var OnboardingUrlManager
*/
private OnboardingUrlManager $url_manager;
/**
* Logger instance, mainly used for debugging purposes.
*
* @var LoggerInterface
*/
private LoggerInterface $logger;
/**
* ID of the current user, set by the process() method.
*
* @var int
*/
private int $user_id;
/**
* Prepare the instance.
*
* @param string $settings_page_id Current plugin settings page ID.
* @param CommonSettings $settings Access to saved connection details.
* @param OnboardingUrlManager $url_manager Get OnboardingURL instances.
* @param ?LoggerInterface $logger The logger, for debugging purposes.
*/
public function __construct( string $settings_page_id, CommonSettings $settings, OnboardingUrlManager $url_manager, LoggerInterface $logger = null ) {
$this->settings_page_id = $settings_page_id;
$this->settings = $settings;
$this->url_manager = $url_manager;
$this->logger = $logger ?: new NullLogger();
// 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 ) );
}
}

View file

@ -14,9 +14,11 @@ use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData;
use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl;
use WooCommerce\WooCommerce\Logging\Logger\NullLogger;
// 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 @@ class ConnectionUrlGenerator {
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 ) {

View file

@ -0,0 +1,101 @@
<?php
/**
* Manages (generates, returns) Onboarding URL instances.
*
* @package WooCommerce\PayPalCommerce\Settings\Service
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Service;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\WooCommerce\Logging\Logger\NullLogger;
// TODO: Replace the OnboardingUrl with a new implementation for this module.
use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl;
/**
* Manages (generates, returns) Onboarding URL instances.
*
* Those instances cannot be generated during boot time, as some details - like
* the user-ID - are not available at that time. This manager allows accessing
* Onboarding URL features at a later point.
*
* It's also a helper to transition from the legacy OnboardingURL to a new class
* without having to re-write all token-related details just yet.
*/
class OnboardingUrlManager {
/**
* Cache instance for onboarding token.
*
* @var Cache
*/
private Cache $cache;
/**
* Logger instance, mainly used for debugging purposes.
*
* @var LoggerInterface
*/
private LoggerInterface $logger;
/**
* Constructor.
*
* @param Cache $cache Cache instance for onboarding token.
* @param ?LoggerInterface $logger The logger, for debugging purposes.
*/
public function __construct( Cache $cache, LoggerInterface $logger = null ) {
$this->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;
}
}

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Settings;
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 @@ class SettingsModule implements ServiceModule, ExecutableModule {
}
);
$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 @@ class SettingsModule implements ServiceModule, ExecutableModule {
}
);
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;
}