Skip to content

Commit 0fbf899

Browse files
Merge pull request #4555 from woocommerce/dev/PCP-6714-unsuccessfully-sandbox-onboarding-via-wizard-connecting-under-button-for-live-account
Unsuccessfully sandbox onboarding via wizard (6714)
2 parents c512ccf + 7c8a74c commit 0fbf899

2 files changed

Lines changed: 154 additions & 68 deletions

File tree

modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { useEffect, useCallback } from '@wordpress/element';
33
import { __ } from '@wordpress/i18n';
44
import classNames from 'classnames';
55
import { OpenSignup } from '@ppcp-settings/Components/ReusableComponents/Icons';
6-
import { useHandleOnboardingButton } from '@ppcp-settings/hooks/useHandleConnections';
6+
import {
7+
useHandleOnboardingButton,
8+
setClickedEnvironment,
9+
} from '@ppcp-settings/hooks/useHandleConnections';
710
import { OnboardingHooks } from '@ppcp-settings/data/onboarding/hooks';
811
import BusyStateWrapper from '@ppcp-settings/Components/ReusableComponents/BusyStateWrapper';
912
import { Notice } from '@ppcp-settings/Components/ReusableComponents/Elements';
@@ -72,6 +75,7 @@ const ConnectionButton = ( {
7275
const {
7376
onboardingUrl,
7477
scriptLoaded,
78+
isActiveEnvironment,
7579
setCompleteHandler,
7680
removeCompleteHandler,
7781
} = useHandleOnboardingButton( isSandbox );
@@ -86,9 +90,41 @@ const ConnectionButton = ( {
8690
} );
8791
const environment = isSandbox ? 'sandbox' : 'production';
8892

89-
const handleButtonClick = useCallback( () => {
90-
setConnectionButtonClicked( true );
91-
}, [ setConnectionButtonClicked ] );
93+
const handleButtonClick = useCallback(
94+
( event ) => {
95+
// Only the button matching the active environment is a real PayPal
96+
// button; the other one is inert.
97+
if ( ! isActiveEnvironment ) {
98+
event.preventDefault();
99+
return;
100+
}
101+
102+
/**
103+
* partner.js wires the anchor to the minibrowser asynchronously and marks
104+
* it as ready by adding `data-secureWindowMsg` / `data-secureButtonMsg`.
105+
* Until then, a click would just follow the href and open the onboarding
106+
* page in a new browser tab - with no minibrowser and no
107+
* `onOnboardComplete` callback, so the connection is never saved. We
108+
* neutralize the click until the button is bound; the next click works.
109+
*/
110+
const anchor = event.currentTarget;
111+
const isBoundToMiniBrowser =
112+
!! anchor?.hasAttribute?.( 'data-securewindowmsg' ) ||
113+
!! anchor?.hasAttribute?.( 'data-securebuttonmsg' );
114+
115+
if ( ! isBoundToMiniBrowser ) {
116+
event.preventDefault();
117+
return;
118+
}
119+
120+
setConnectionButtonClicked( true );
121+
122+
// Record which environment the merchant clicked so the shared
123+
// onOnboardComplete handler authenticates against the right account.
124+
setClickedEnvironment( environment );
125+
},
126+
[ isActiveEnvironment, setConnectionButtonClicked, environment ]
127+
);
92128

93129
// Reset button clicked state when onboardingUrl becomes available.
94130
useEffect( () => {
@@ -100,7 +136,7 @@ const ConnectionButton = ( {
100136
useEffect( () => {
101137
if ( scriptLoaded && onboardingUrl ) {
102138
window.PAYPAL.apps.Signup.render();
103-
setCompleteHandler( environment );
139+
setCompleteHandler();
104140
}
105141

106142
return () => {
@@ -109,18 +145,17 @@ const ConnectionButton = ( {
109145
}, [
110146
scriptLoaded,
111147
onboardingUrl,
112-
environment,
113148
setCompleteHandler,
114149
removeCompleteHandler,
115150
] );
116151

117152
return (
118-
<BusyStateWrapper isBusy={ ! onboardingUrl }>
153+
<BusyStateWrapper isBusy={ isActiveEnvironment && ! onboardingUrl }>
119154
<ButtonOrPlaceholder
120155
className={ buttonClassName }
121156
variant={ variant }
122157
showIcon={ showIcon }
123-
href={ onboardingUrl }
158+
href={ isActiveEnvironment ? onboardingUrl : undefined }
124159
onClick={ handleButtonClick }
125160
>
126161
<span className="button-title">{ title }</span>

modules/ppcp-settings/resources/js/hooks/useHandleConnections.js

Lines changed: 111 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,21 @@ import { CommonHooks, OnboardingHooks } from '@ppcp-settings/data';
66
import { useStoreManager } from './useStoreManager';
77
import useNotices from './useNotices';
88

9-
const PAYPAL_PARTNER_SDK_URL =
10-
'https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js';
9+
/**
10+
* PayPal's onboarding SDK (partner.js) is environment-specific and offers no
11+
* namespace support: it always uses the single `window.PAYPAL` global, derives
12+
* the environment from its own script domain, and only wires up the *first*
13+
* `[data-paypal-button]` element it finds. We therefore load the SDK for the
14+
* active environment and make sure only that environment's button is a real
15+
* `[data-paypal-button]`, so partner.js binds the correct button to the
16+
* minibrowser (see `useHandleOnboardingButton`).
17+
*/
18+
const PAYPAL_PARTNER_SDK_URL = {
19+
production:
20+
'https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js',
21+
sandbox:
22+
'https://www.sandbox.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js',
23+
};
1124

1225
const MESSAGES = {
1326
CONNECTED: __( 'Connected to PayPal', 'woocommerce-paypal-payments' ),
@@ -31,10 +44,40 @@ const ACTIVITIES = {
3144
API_VERIFY: 'auth/verify-login',
3245
};
3346

47+
/**
48+
* Environment ('sandbox' | 'production') of the connection button the merchant
49+
* actually clicked.
50+
*
51+
* PayPal's partner.js exposes a single global onOnboardComplete callback, but the
52+
* onboarding step mounts a live and a sandbox button at the same time. This
53+
* module-level value lets the shared completion handler authenticate against the
54+
* environment the merchant chose, rather than whichever button registered its
55+
* handler first.
56+
*
57+
* @type {?string}
58+
*/
59+
let clickedEnvironment = null;
60+
61+
export const setClickedEnvironment = ( environment ) => {
62+
clickedEnvironment = environment;
63+
};
64+
3465
export const useHandleOnboardingButton = ( isSandbox ) => {
3566
const { onboardingUrl } = isSandbox
3667
? CommonHooks.useSandbox()
3768
: CommonHooks.useProduction();
69+
const { isSandboxMode } = CommonHooks.useSandbox();
70+
71+
/**
72+
* partner.js only wires up a single button and derives its environment from
73+
* its own script domain, so the live and sandbox SDKs cannot coexist. Only
74+
* the button whose environment matches the "Enable Sandbox Mode" toggle is
75+
* the active one: it loads the matching SDK and is rendered as a real
76+
* `[data-paypal-button]`. The other button is rendered inert (no href /
77+
* data-attributes) so partner.js ignores it.
78+
*/
79+
const isActiveEnvironment = isSandbox === !! isSandboxMode;
80+
3881
const { ownBrandOnly, storeCountry } = CommonHooks.useWooSettings();
3982
const { products, options } = OnboardingHooks.useDetermineProducts(
4083
ownBrandOnly,
@@ -69,85 +112,92 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
69112

70113
useEffect( () => {
71114
/**
72-
* The partner.js script initializes all onboarding buttons in the onload event.
73-
* When no buttons are present, a JS error is displayed; i.e. we should load this script
115+
* The partner.js script initializes the onboarding button in its onload event.
116+
* When no button is present, a JS error is displayed; i.e. we should load this script
74117
* only when the button is ready (with a valid href and data-attributes).
118+
*
119+
* We load the SDK only for the button matching the active environment. Because
120+
* partner.js keeps its state on the single `window.PAYPAL` global (and derives
121+
* the environment from its own script domain), we reset that global and remove
122+
* the previously injected scripts before loading, so switching environments
123+
* re-initializes cleanly against the correct domain.
75124
*/
76-
if ( ! onboardingUrlState ) {
125+
if ( ! onboardingUrlState || ! isActiveEnvironment ) {
77126
return;
78127
}
79128

129+
// partner.js injects signup-js and rampConfig-js itself; remove all three
130+
// so switching between environments starts from a clean slate.
131+
const removeOnboardingScripts = () => {
132+
[
133+
'partner-js',
134+
'signup-js',
135+
'rampConfig-js',
136+
'zoidMiniBrowser-js',
137+
].forEach( ( id ) => {
138+
const el = document.querySelector( `script[id="${ id }"]` );
139+
140+
if ( el?.parentNode ) {
141+
el.parentNode.removeChild( el );
142+
}
143+
} );
144+
};
145+
146+
removeOnboardingScripts();
147+
delete window.PAYPAL;
148+
80149
const script = document.createElement( 'script' );
81150
script.id = 'partner-js';
82-
script.src = PAYPAL_PARTNER_SDK_URL;
151+
script.src = isSandbox
152+
? PAYPAL_PARTNER_SDK_URL.sandbox
153+
: PAYPAL_PARTNER_SDK_URL.production;
83154
script.onload = () => {
84155
setScriptLoaded( true );
85156
};
86157
document.body.appendChild( script );
87158

88159
return () => {
160+
removeOnboardingScripts();
161+
delete window.PAYPAL;
162+
setScriptLoaded( false );
163+
};
164+
}, [ onboardingUrlState, isActiveEnvironment, isSandbox ] );
165+
166+
const setCompleteHandler = useCallback( () => {
167+
const onComplete = async ( authCode, sharedId ) => {
89168
/**
90-
* When the component is unmounted, remove the partner.js script, as well as the
91-
* dynamic scripts it loaded (signup-js and rampConfig-js)
169+
* Until now, the full page is blocked by PayPal's semi-transparent, black overlay.
170+
* But at this point, the overlay is removed, while we process the sharedId and
171+
* authCode via a REST call.
92172
*
93-
* This is important, as the onboarding button is only initialized during the onload
94-
* event of those scripts; i.e. we need to load the scripts again, when the button is
95-
* rendered again.
173+
* Note: The REST response is irrelevant, since PayPal will most likely refresh this
174+
* frame before the REST endpoint returns a value. Using "withActivity" is more of a
175+
* visual cue to the user that something is still processing in the background.
96176
*/
97-
const onboardingScripts = [
98-
'partner-js',
99-
'signup-js',
100-
'rampConfig-js',
101-
];
102-
103-
onboardingScripts.forEach( ( id ) => {
104-
const el = document.querySelector( `script[id="${ id }"]` );
177+
startActivity(
178+
ACTIVITIES.OAUTH_VERIFY,
179+
'Validating the connection details'
180+
);
105181

106-
if ( el?.parentNode ) {
107-
el.parentNode.removeChild( el );
108-
}
109-
} );
182+
await authenticateWithOAuth(
183+
sharedId,
184+
authCode,
185+
clickedEnvironment === 'sandbox'
186+
);
110187
};
111-
}, [ onboardingUrlState ] );
112-
113-
const setCompleteHandler = useCallback(
114-
( environment ) => {
115-
const onComplete = async ( authCode, sharedId ) => {
116-
/**
117-
* Until now, the full page is blocked by PayPal's semi-transparent, black overlay.
118-
* But at this point, the overlay is removed, while we process the sharedId and
119-
* authCode via a REST call.
120-
*
121-
* Note: The REST response is irrelevant, since PayPal will most likely refresh this
122-
* frame before the REST endpoint returns a value. Using "withActivity" is more of a
123-
* visual cue to the user that something is still processing in the background.
124-
*/
125-
startActivity(
126-
ACTIVITIES.OAUTH_VERIFY,
127-
'Validating the connection details'
128-
);
129-
130-
await authenticateWithOAuth(
131-
sharedId,
132-
authCode,
133-
environment === 'sandbox'
134-
);
135-
};
136188

137-
const addHandler = () => {
138-
const MiniBrowser = window.PAYPAL?.apps?.Signup?.MiniBrowser;
139-
if ( ! MiniBrowser || MiniBrowser.onOnboardComplete ) {
140-
return;
141-
}
189+
const addHandler = () => {
190+
const MiniBrowser = window.PAYPAL?.apps?.Signup?.MiniBrowser;
191+
if ( ! MiniBrowser || MiniBrowser.onOnboardComplete ) {
192+
return;
193+
}
142194

143-
MiniBrowser.onOnboardComplete = onComplete;
144-
};
195+
MiniBrowser.onOnboardComplete = onComplete;
196+
};
145197

146-
// Ensure the onComplete handler is not removed by a PayPal init script.
147-
timerRef.current = setInterval( addHandler, 250 );
148-
},
149-
[ authenticateWithOAuth, startActivity ]
150-
);
198+
// Ensure the onComplete handler is not removed by a PayPal init script.
199+
timerRef.current = setInterval( addHandler, 250 );
200+
}, [ authenticateWithOAuth, startActivity ] );
151201

152202
const removeCompleteHandler = useCallback( () => {
153203
if ( timerRef.current ) {
@@ -161,6 +211,7 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
161211
return {
162212
onboardingUrl: onboardingUrlState,
163213
scriptLoaded,
214+
isActiveEnvironment,
164215
setCompleteHandler,
165216
removeCompleteHandler,
166217
};

0 commit comments

Comments
 (0)