Merge pull request #2898 from woocommerce/PCP-3914-finish-production-login-logic

Add all options to the ISU login logic (3914)
This commit is contained in:
Philipp Stracker 2024-12-10 16:12:41 +01:00 committed by GitHub
commit 4d2c9fce10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1427 additions and 332 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

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

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

@ -6,8 +6,7 @@ import Navigation from './Components/Navigation';
import { useEffect } from '@wordpress/element';
const Onboarding = () => {
const { step, setStep, setCompleted, flags } = OnboardingHooks.useSteps();
const { step, setStep, flags } = OnboardingHooks.useSteps();
const Steps = getSteps( flags );
const currentStep = getCurrentStep( step, Steps );
@ -45,7 +44,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

@ -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,20 +1,37 @@
import { useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { OnboardingHooks } from '../../data';
import SpinnerOverlay from '../ReusableComponents/SpinnerOverlay';
import Onboarding from './Onboarding/Onboarding';
import SettingsScreen from './SettingsScreen';
const Settings = () => {
const onboardingProgress = OnboardingHooks.useSteps();
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,
};
};