@easypayment/medusa-payment-paypal 0.9.21 → 0.9.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/index.js +200 -326
- package/.medusa/server/src/admin/index.mjs +200 -326
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +208 -379
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +234 -423
|
@@ -17,26 +17,25 @@ const PARTNER_JS_URLS = {
|
|
|
17
17
|
live: "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js",
|
|
18
18
|
};
|
|
19
19
|
const SERVICE_URL = "/admin/paypal/onboarding-link";
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// rather than a blocked window or a new tab. The window name matches the
|
|
23
|
-
// anchor's `target` and PayPal's conventional "PPFrame": partner.js opens the
|
|
24
|
-
// flow with `window.open(url, target, ...)`, so when it runs right after us it
|
|
25
|
-
// *reuses this already-open window* (a named-window navigation needs no user
|
|
26
|
-
// activation) instead of trying — and failing, asynchronously — to spawn its
|
|
27
|
-
// own. That lets partner.js still wire its completion bridge without ever
|
|
28
|
-
// opening a second/late window.
|
|
20
|
+
// PayPal's conventional mini-browser window name; also used as the anchor's
|
|
21
|
+
// `target` so partner.js opens the flow in a popup.
|
|
29
22
|
const POPUP_NAME = "PPFrame";
|
|
23
|
+
// The onboarding link is cached in the browser for 6 hours. This is the heart of
|
|
24
|
+
// the integration: partner.js can only wire the mini-browser + completion
|
|
25
|
+
// callback if the anchor already carries a valid onboarding href when partner.js
|
|
26
|
+
// initializes. By caching the link (and reloading once to populate the cache on
|
|
27
|
+
// a cold start) the link is always present on page load, so partner.js can take
|
|
28
|
+
// over the click and run its flow exactly as documented.
|
|
30
29
|
const CACHE_PREFIX = "pp_onboard_cache";
|
|
31
|
-
const CACHE_EXPIRY =
|
|
30
|
+
const CACHE_EXPIRY = 6 * 60 * 60 * 1000; // 6 hours
|
|
32
31
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
|
|
33
32
|
const STATUS_ENDPOINT = "/admin/paypal/status";
|
|
34
33
|
const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials";
|
|
35
34
|
const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect";
|
|
36
35
|
const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment";
|
|
37
|
-
// Onboarding URLs are environment-specific
|
|
38
|
-
//
|
|
39
|
-
//
|
|
36
|
+
// Onboarding URLs are environment-specific, so the cache is keyed per
|
|
37
|
+
// environment — a single shared key let a stale sandbox URL leak into the live
|
|
38
|
+
// flow (and vice-versa).
|
|
40
39
|
const cacheKeyFor = (env) => `${CACHE_PREFIX}_${env}`;
|
|
41
40
|
function readCachedUrl(env) {
|
|
42
41
|
if (typeof window === "undefined")
|
|
@@ -57,12 +56,15 @@ function readCachedUrl(env) {
|
|
|
57
56
|
}
|
|
58
57
|
return null;
|
|
59
58
|
}
|
|
59
|
+
// Returns true only if the value was actually persisted (storage enabled), which
|
|
60
|
+
// lets the caller decide whether reloading to pick up the cache is safe.
|
|
60
61
|
function writeCachedUrl(env, url) {
|
|
61
62
|
try {
|
|
62
63
|
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
|
|
64
|
+
return readCachedUrl(env) === url;
|
|
63
65
|
}
|
|
64
66
|
catch {
|
|
65
|
-
|
|
67
|
+
return false;
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
function clearCachedUrl(env) {
|
|
@@ -79,80 +81,18 @@ function clearCachedUrl(env) {
|
|
|
79
81
|
/* ignore */
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
|
-
// PayPal delivers the seamless-onboarding result by posting a message to
|
|
83
|
-
// window.opener. We validate the sender is actually a PayPal origin before
|
|
84
|
-
// trusting authCode/sharedId.
|
|
85
|
-
function isPayPalOrigin(origin) {
|
|
86
|
-
try {
|
|
87
|
-
const host = new URL(origin).hostname.toLowerCase();
|
|
88
|
-
return (host === "www.paypal.com" ||
|
|
89
|
-
host === "www.sandbox.paypal.com" ||
|
|
90
|
-
host.endsWith(".paypal.com") ||
|
|
91
|
-
host.endsWith(".paypalobjects.com"));
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
// The completion message shape is not strongly documented, so extract
|
|
98
|
-
// authCode/sharedId defensively from objects, JSON strings, or query strings.
|
|
99
|
-
// We only ever act when BOTH values are present, which keeps the many unrelated
|
|
100
|
-
// intermediate postMessages PayPal emits from triggering a false completion.
|
|
101
|
-
function extractAuth(data) {
|
|
102
|
-
if (!data)
|
|
103
|
-
return {};
|
|
104
|
-
if (typeof data === "object") {
|
|
105
|
-
const obj = data;
|
|
106
|
-
const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode;
|
|
107
|
-
const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid;
|
|
108
|
-
if (authCode && sharedId) {
|
|
109
|
-
return { authCode: String(authCode), sharedId: String(sharedId) };
|
|
110
|
-
}
|
|
111
|
-
for (const key of ["data", "payload", "message", "detail", "body"]) {
|
|
112
|
-
if (obj[key] && typeof obj[key] === "object") {
|
|
113
|
-
const inner = extractAuth(obj[key]);
|
|
114
|
-
if (inner.authCode && inner.sharedId)
|
|
115
|
-
return inner;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return {};
|
|
119
|
-
}
|
|
120
|
-
if (typeof data === "string") {
|
|
121
|
-
const s = data.trim();
|
|
122
|
-
if (!s)
|
|
123
|
-
return {};
|
|
124
|
-
if (s.startsWith("{")) {
|
|
125
|
-
try {
|
|
126
|
-
return extractAuth(JSON.parse(s));
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
/* not JSON */
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
|
|
133
|
-
try {
|
|
134
|
-
const sp = new URLSearchParams(s.replace(/^[?#]/, ""));
|
|
135
|
-
const authCode = sp.get("authCode") || sp.get("auth_code") || undefined;
|
|
136
|
-
const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined;
|
|
137
|
-
if (authCode && sharedId)
|
|
138
|
-
return { authCode, sharedId };
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
/* not a query string */
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return {};
|
|
146
|
-
}
|
|
147
84
|
function PayPalConnectionPage() {
|
|
148
85
|
const [env, setEnv] = (0, react_1.useState)("live");
|
|
149
|
-
// We must
|
|
150
|
-
//
|
|
151
|
-
// GET /environment response and can target the wrong environment.
|
|
86
|
+
// We must know the server's environment before generating/looking up a link so
|
|
87
|
+
// the per-environment cache and the generated link always match.
|
|
152
88
|
const [envReady, setEnvReady] = (0, react_1.useState)(false);
|
|
153
89
|
const [connState, setConnState] = (0, react_1.useState)("loading");
|
|
90
|
+
// True once partner.js has loaded and bound its click interceptor to the
|
|
91
|
+
// button. The button stays in the DOM but is not clickable until then, so a
|
|
92
|
+
// click can never fall through to the native anchor (which would open a tab
|
|
93
|
+
// without partner.js's onboarding flow / completion callback).
|
|
94
|
+
const [partnerReady, setPartnerReady] = (0, react_1.useState)(false);
|
|
154
95
|
const [error, setError] = (0, react_1.useState)(null);
|
|
155
|
-
const [popupBlocked, setPopupBlocked] = (0, react_1.useState)(false);
|
|
156
96
|
const [finalUrl, setFinalUrl] = (0, react_1.useState)("");
|
|
157
97
|
const [showManual, setShowManual] = (0, react_1.useState)(false);
|
|
158
98
|
const [clientId, setClientId] = (0, react_1.useState)("");
|
|
@@ -160,24 +100,13 @@ function PayPalConnectionPage() {
|
|
|
160
100
|
const [statusInfo, setStatusInfo] = (0, react_1.useState)(null);
|
|
161
101
|
const [onboardingInProgress, setOnboardingInProgress] = (0, react_1.useState)(false);
|
|
162
102
|
const initLoaderRef = (0, react_1.useRef)(null);
|
|
163
|
-
const paypalButtonRef = (0, react_1.useRef)(null);
|
|
164
103
|
const errorLogRef = (0, react_1.useRef)(null);
|
|
165
104
|
const runIdRef = (0, react_1.useRef)(0);
|
|
166
105
|
const currentRunId = (0, react_1.useRef)(0);
|
|
167
|
-
// Live mirrors of state so the document-level capture click listener (set up
|
|
168
|
-
// once) and the stable completion callback always see current values.
|
|
169
|
-
const finalUrlRef = (0, react_1.useRef)(finalUrl);
|
|
170
|
-
finalUrlRef.current = finalUrl;
|
|
171
|
-
const connStateRef = (0, react_1.useRef)(connState);
|
|
172
|
-
connStateRef.current = connState;
|
|
173
|
-
const inProgressRef = (0, react_1.useRef)(onboardingInProgress);
|
|
174
|
-
inProgressRef.current = onboardingInProgress;
|
|
175
106
|
const envRef = (0, react_1.useRef)(env);
|
|
176
107
|
envRef.current = env;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// Guards against the two redundant completion paths (our own message listener
|
|
180
|
-
// and partner.js's onboardingCallback bridge) both POSTing the same result.
|
|
108
|
+
// Guards against partner.js's bridge invoking the completion callback more
|
|
109
|
+
// than once.
|
|
181
110
|
const completedRef = (0, react_1.useRef)(false);
|
|
182
111
|
const ppBtnMeasureRef = (0, react_1.useRef)(null);
|
|
183
112
|
const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
|
|
@@ -188,154 +117,27 @@ function PayPalConnectionPage() {
|
|
|
188
117
|
setConnState("error");
|
|
189
118
|
setError(msg);
|
|
190
119
|
}, []);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// in a user gesture. Because finalUrl is already in state at click time, we
|
|
201
|
-
// never need an await before opening, and we never depend on winning a race
|
|
202
|
-
// with partner.js's asynchronous click binding.
|
|
203
|
-
const openOnboardingPopup = (0, react_1.useCallback)((url) => {
|
|
204
|
-
const w = 450;
|
|
205
|
-
const h = 600;
|
|
206
|
-
const baseLeft = typeof window.screenX === "number" ? window.screenX : window.screenLeft || 0;
|
|
207
|
-
const baseTop = typeof window.screenY === "number" ? window.screenY : window.screenTop || 0;
|
|
208
|
-
const outerW = window.outerWidth || document.documentElement.clientWidth || 1024;
|
|
209
|
-
const outerH = window.outerHeight || document.documentElement.clientHeight || 768;
|
|
210
|
-
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2));
|
|
211
|
-
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2));
|
|
212
|
-
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`;
|
|
213
|
-
let popup = null;
|
|
214
|
-
try {
|
|
215
|
-
popup = window.open(url, POPUP_NAME, features);
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
popup = null;
|
|
219
|
-
}
|
|
220
|
-
if (popup) {
|
|
221
|
-
popupRef.current = popup;
|
|
222
|
-
try {
|
|
223
|
-
popup.focus();
|
|
224
|
-
}
|
|
225
|
-
catch {
|
|
226
|
-
/* ignore */
|
|
120
|
+
// Generate a fresh onboarding link, cache it for 6 hours, then reload the page
|
|
121
|
+
// so that on the next load the cached link is already present for partner.js.
|
|
122
|
+
// If browser storage is unavailable we fall back to using the link in-place
|
|
123
|
+
// (no reload) so we can never get stuck in a reload loop.
|
|
124
|
+
const generateLinkAndReload = (0, react_1.useCallback)(async (targetEnv, runId) => {
|
|
125
|
+
if (initLoaderRef.current) {
|
|
126
|
+
const loaderText = initLoaderRef.current.querySelector("#loader-text");
|
|
127
|
+
if (loaderText) {
|
|
128
|
+
loaderText.textContent = "Generating onboarding session...";
|
|
227
129
|
}
|
|
228
|
-
stopPopupPoll();
|
|
229
|
-
popupPollRef.current = setInterval(() => {
|
|
230
|
-
if (!popupRef.current || popupRef.current.closed) {
|
|
231
|
-
stopPopupPoll();
|
|
232
|
-
}
|
|
233
|
-
}, 1000);
|
|
234
|
-
}
|
|
235
|
-
return popup;
|
|
236
|
-
}, [stopPopupPoll]);
|
|
237
|
-
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
238
|
-
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
239
|
-
// listener; completedRef makes it idempotent so we only POST once.
|
|
240
|
-
const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
|
|
241
|
-
if (!authCode || !sharedId)
|
|
242
|
-
return;
|
|
243
|
-
if (completedRef.current)
|
|
244
|
-
return;
|
|
245
|
-
completedRef.current = true;
|
|
246
|
-
try {
|
|
247
|
-
window.onbeforeunload = null;
|
|
248
|
-
}
|
|
249
|
-
catch {
|
|
250
|
-
/* ignore */
|
|
251
130
|
}
|
|
252
|
-
const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
|
|
253
|
-
setOnboardingInProgress(true);
|
|
254
|
-
setConnState("loading");
|
|
255
|
-
setError(null);
|
|
256
|
-
setPopupBlocked(false);
|
|
257
131
|
try {
|
|
258
|
-
const res = await fetch(
|
|
132
|
+
const res = await fetch(SERVICE_URL, {
|
|
259
133
|
method: "POST",
|
|
260
134
|
headers: { "content-type": "application/json" },
|
|
261
135
|
credentials: "include",
|
|
262
|
-
body: JSON.stringify({
|
|
136
|
+
body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
|
|
263
137
|
});
|
|
264
|
-
if (!res.ok)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
// Close the popup we opened, plus any partner.js-managed flow.
|
|
269
|
-
try {
|
|
270
|
-
popupRef.current?.close();
|
|
271
|
-
}
|
|
272
|
-
catch {
|
|
273
|
-
/* ignore */
|
|
274
|
-
}
|
|
275
|
-
stopPopupPoll();
|
|
276
|
-
try {
|
|
277
|
-
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
|
|
278
|
-
if (typeof close1 === "function")
|
|
279
|
-
close1();
|
|
280
|
-
}
|
|
281
|
-
catch {
|
|
282
|
-
/* ignore */
|
|
283
|
-
}
|
|
284
|
-
try {
|
|
285
|
-
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
286
|
-
window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
|
|
287
|
-
if (typeof close2 === "function")
|
|
288
|
-
close2();
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
/* ignore */
|
|
292
|
-
}
|
|
293
|
-
clearCachedUrl(activeEnv);
|
|
294
|
-
try {
|
|
295
|
-
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, { method: "GET", credentials: "include" });
|
|
296
|
-
const refreshedStatus = await statusRes.json().catch(() => ({}));
|
|
297
|
-
setStatusInfo(refreshedStatus || null);
|
|
298
|
-
}
|
|
299
|
-
catch {
|
|
300
|
-
/* ignore — still consider it connected below */
|
|
301
|
-
}
|
|
302
|
-
setConnState("connected");
|
|
303
|
-
setShowManual(false);
|
|
304
|
-
}
|
|
305
|
-
catch (e) {
|
|
306
|
-
// Allow a retry after a failed exchange.
|
|
307
|
-
completedRef.current = false;
|
|
308
|
-
console.error(e);
|
|
309
|
-
setConnState("error");
|
|
310
|
-
setError(e?.message || "Exchange failed while saving credentials.");
|
|
311
|
-
}
|
|
312
|
-
finally {
|
|
313
|
-
setOnboardingInProgress(false);
|
|
314
|
-
}
|
|
315
|
-
}, [stopPopupPoll]);
|
|
316
|
-
const fetchFreshLink = (0, react_1.useCallback)((runId, targetEnv) => {
|
|
317
|
-
if (initLoaderRef.current) {
|
|
318
|
-
const loaderText = initLoaderRef.current.querySelector("#loader-text");
|
|
319
|
-
if (loaderText)
|
|
320
|
-
loaderText.textContent = "Generating onboarding session...";
|
|
321
|
-
}
|
|
322
|
-
fetch(SERVICE_URL, {
|
|
323
|
-
method: "POST",
|
|
324
|
-
headers: { "content-type": "application/json" },
|
|
325
|
-
credentials: "include",
|
|
326
|
-
body: JSON.stringify({
|
|
327
|
-
products: ["PPCP"],
|
|
328
|
-
// Generate the link for the explicitly selected environment so it can
|
|
329
|
-
// never depend on a racing POST /environment having landed yet.
|
|
330
|
-
environment: targetEnv,
|
|
331
|
-
}),
|
|
332
|
-
})
|
|
333
|
-
.then((r) => {
|
|
334
|
-
if (!r.ok)
|
|
335
|
-
throw new Error(`Service returned ${r.status}`);
|
|
336
|
-
return r.json();
|
|
337
|
-
})
|
|
338
|
-
.then((data) => {
|
|
138
|
+
if (!res.ok)
|
|
139
|
+
throw new Error(`Service returned ${res.status}`);
|
|
140
|
+
const data = await res.json();
|
|
339
141
|
if (runId !== currentRunId.current)
|
|
340
142
|
return;
|
|
341
143
|
const href = data?.onboarding_url;
|
|
@@ -344,17 +146,25 @@ function PayPalConnectionPage() {
|
|
|
344
146
|
return;
|
|
345
147
|
}
|
|
346
148
|
const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
|
|
347
|
-
writeCachedUrl(targetEnv, url);
|
|
149
|
+
const persisted = writeCachedUrl(targetEnv, url);
|
|
150
|
+
if (persisted) {
|
|
151
|
+
// The cache survived; reload so the link is present at first render and
|
|
152
|
+
// partner.js initializes against it. The post-reload load finds the
|
|
153
|
+
// cache (no regeneration), so this can't loop.
|
|
154
|
+
window.location.reload();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Storage disabled — use the link directly without reloading.
|
|
348
158
|
setFinalUrl(url);
|
|
349
159
|
setConnState("ready");
|
|
350
|
-
}
|
|
351
|
-
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
352
162
|
if (runId !== currentRunId.current)
|
|
353
163
|
return;
|
|
354
164
|
showError("Unable to connect to service.");
|
|
355
|
-
}
|
|
165
|
+
}
|
|
356
166
|
}, [showError]);
|
|
357
|
-
// Resolve the server
|
|
167
|
+
// Resolve the server environment first.
|
|
358
168
|
(0, react_1.useEffect)(() => {
|
|
359
169
|
let alive = true;
|
|
360
170
|
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
@@ -373,20 +183,80 @@ function PayPalConnectionPage() {
|
|
|
373
183
|
alive = false;
|
|
374
184
|
};
|
|
375
185
|
}, []);
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
186
|
+
// Page-load flow:
|
|
187
|
+
// 1. If already connected, show the connected state.
|
|
188
|
+
// 2. Else if a valid cached link exists, use it (partner.js will bind it).
|
|
189
|
+
// 3. Else generate a link, cache it for 6h, and reload so the cached link is
|
|
190
|
+
// present for partner.js on the next load.
|
|
191
|
+
(0, react_1.useEffect)(() => {
|
|
192
|
+
if (!envReady)
|
|
193
|
+
return;
|
|
194
|
+
currentRunId.current = ++runIdRef.current;
|
|
195
|
+
const runId = currentRunId.current;
|
|
196
|
+
let cancelled = false;
|
|
197
|
+
completedRef.current = false;
|
|
198
|
+
const run = async () => {
|
|
199
|
+
setConnState("loading");
|
|
200
|
+
setError(null);
|
|
201
|
+
setFinalUrl("");
|
|
202
|
+
try {
|
|
203
|
+
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
204
|
+
method: "GET",
|
|
205
|
+
credentials: "include",
|
|
206
|
+
});
|
|
207
|
+
const st = await r.json().catch(() => ({}));
|
|
208
|
+
if (cancelled || runId !== currentRunId.current)
|
|
209
|
+
return;
|
|
210
|
+
setStatusInfo(st);
|
|
211
|
+
const isConnected = st?.status === "connected" && st?.seller_client_id_present === true;
|
|
212
|
+
if (isConnected) {
|
|
213
|
+
setConnState("connected");
|
|
214
|
+
setShowManual(false);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
console.error(e);
|
|
220
|
+
}
|
|
221
|
+
if (cancelled || runId !== currentRunId.current)
|
|
222
|
+
return;
|
|
223
|
+
const cached = readCachedUrl(env);
|
|
224
|
+
if (cached) {
|
|
225
|
+
setFinalUrl(cached);
|
|
226
|
+
setConnState("ready");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await generateLinkAndReload(env, runId);
|
|
230
|
+
};
|
|
231
|
+
run();
|
|
232
|
+
return () => {
|
|
233
|
+
cancelled = true;
|
|
234
|
+
};
|
|
235
|
+
}, [env, envReady, generateLinkAndReload]);
|
|
236
|
+
// Load partner.js once the anchor exists with its real (cached) href, then run
|
|
237
|
+
// PayPal's initializer. In a React SPA partner.js's own DOMContentLoaded hook
|
|
238
|
+
// already fired before this route mounted, so we must call init()/render()
|
|
239
|
+
// ourselves for it to discover the button and take over the click into the
|
|
240
|
+
// mini-browser — opening the popup, and wiring its postMessage bridge that
|
|
241
|
+
// invokes window.onboardingCallback and closes the popup on completion.
|
|
384
242
|
(0, react_1.useEffect)(() => {
|
|
385
243
|
if (connState !== "ready" || !finalUrl)
|
|
386
244
|
return;
|
|
387
245
|
const scriptUrl = PARTNER_JS_URLS[env];
|
|
388
246
|
let cancelled = false;
|
|
389
247
|
let pollTimer;
|
|
248
|
+
// The button is gated on partnerReady; reset it for this (re)load.
|
|
249
|
+
setPartnerReady(false);
|
|
250
|
+
// Safety net: if partner.js can't load (e.g. blocked), enable the button
|
|
251
|
+
// anyway after a few seconds rather than leaving it permanently disabled.
|
|
252
|
+
const readyFallback = setTimeout(() => {
|
|
253
|
+
if (!cancelled)
|
|
254
|
+
setPartnerReady(true);
|
|
255
|
+
}, 6000);
|
|
256
|
+
const markReady = () => {
|
|
257
|
+
if (!cancelled)
|
|
258
|
+
setPartnerReady(true);
|
|
259
|
+
};
|
|
390
260
|
const initPartner = (attempt = 0) => {
|
|
391
261
|
if (cancelled)
|
|
392
262
|
return;
|
|
@@ -398,14 +268,12 @@ function PayPalConnectionPage() {
|
|
|
398
268
|
: null;
|
|
399
269
|
if (target?.init) {
|
|
400
270
|
try {
|
|
401
|
-
// Wires partner.js's click interceptor + its postMessage->callback
|
|
402
|
-
// bridge. It will adopt the window we pre-open (same name) rather than
|
|
403
|
-
// open its own.
|
|
404
271
|
target.init();
|
|
405
272
|
}
|
|
406
273
|
catch (e) {
|
|
407
274
|
console.error("[paypal] partner.js init failed:", e);
|
|
408
275
|
}
|
|
276
|
+
markReady();
|
|
409
277
|
return;
|
|
410
278
|
}
|
|
411
279
|
if (typeof signup?.render === "function") {
|
|
@@ -415,8 +283,11 @@ function PayPalConnectionPage() {
|
|
|
415
283
|
catch (e) {
|
|
416
284
|
console.error("[paypal] partner.js render failed:", e);
|
|
417
285
|
}
|
|
286
|
+
markReady();
|
|
418
287
|
return;
|
|
419
288
|
}
|
|
289
|
+
// The namespace may not be populated the instant onload fires; retry
|
|
290
|
+
// briefly before giving up.
|
|
420
291
|
if (attempt < 40) {
|
|
421
292
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
|
|
422
293
|
return;
|
|
@@ -425,6 +296,7 @@ function PayPalConnectionPage() {
|
|
|
425
296
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
426
297
|
}
|
|
427
298
|
catch { }
|
|
299
|
+
markReady();
|
|
428
300
|
};
|
|
429
301
|
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
430
302
|
// the current environment (sandbox vs live partner.js differ).
|
|
@@ -443,9 +315,11 @@ function PayPalConnectionPage() {
|
|
|
443
315
|
ppScript.src = scriptUrl;
|
|
444
316
|
ppScript.async = true;
|
|
445
317
|
ppScript.onload = () => initPartner();
|
|
318
|
+
ppScript.onerror = () => markReady();
|
|
446
319
|
document.body.appendChild(ppScript);
|
|
447
320
|
return () => {
|
|
448
321
|
cancelled = true;
|
|
322
|
+
clearTimeout(readyFallback);
|
|
449
323
|
if (pollTimer) {
|
|
450
324
|
clearTimeout(pollTimer);
|
|
451
325
|
}
|
|
@@ -454,72 +328,72 @@ function PayPalConnectionPage() {
|
|
|
454
328
|
}
|
|
455
329
|
};
|
|
456
330
|
}, [connState, finalUrl, env]);
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
(0, react_1.
|
|
461
|
-
|
|
462
|
-
if (!isPayPalOrigin(ev.origin))
|
|
463
|
-
return;
|
|
464
|
-
const { authCode, sharedId } = extractAuth(ev.data);
|
|
465
|
-
if (authCode && sharedId) {
|
|
466
|
-
completeOnboarding(authCode, sharedId);
|
|
467
|
-
}
|
|
468
|
-
};
|
|
469
|
-
window.addEventListener("message", onMessage);
|
|
470
|
-
return () => window.removeEventListener("message", onMessage);
|
|
471
|
-
}, [completeOnboarding]);
|
|
472
|
-
// Status check + link generation. Gated on envReady so we always use the
|
|
473
|
-
// correct environment. runId guards against a stale response from a previous
|
|
474
|
-
// environment overwriting the current one.
|
|
475
|
-
(0, react_1.useEffect)(() => {
|
|
476
|
-
if (!envReady)
|
|
331
|
+
// partner.js calls this (by the name in data-paypal-onboard-complete) when the
|
|
332
|
+
// seller finishes onboarding, passing the authCode + sharedId to exchange for
|
|
333
|
+
// seller credentials.
|
|
334
|
+
const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
|
|
335
|
+
if (!authCode || !sharedId)
|
|
477
336
|
return;
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
337
|
+
if (completedRef.current)
|
|
338
|
+
return;
|
|
339
|
+
completedRef.current = true;
|
|
340
|
+
try {
|
|
341
|
+
window.onbeforeunload = null;
|
|
342
|
+
}
|
|
343
|
+
catch { }
|
|
344
|
+
const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
|
|
345
|
+
setOnboardingInProgress(true);
|
|
346
|
+
setConnState("loading");
|
|
347
|
+
setError(null);
|
|
348
|
+
try {
|
|
349
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "content-type": "application/json" },
|
|
352
|
+
credentials: "include",
|
|
353
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
const txt = await res.text().catch(() => "");
|
|
357
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`);
|
|
358
|
+
}
|
|
359
|
+
// Close the partner.js mini-browser.
|
|
487
360
|
try {
|
|
488
|
-
const
|
|
361
|
+
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
|
|
362
|
+
if (typeof close1 === "function")
|
|
363
|
+
close1();
|
|
364
|
+
}
|
|
365
|
+
catch { }
|
|
366
|
+
try {
|
|
367
|
+
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
368
|
+
window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
|
|
369
|
+
if (typeof close2 === "function")
|
|
370
|
+
close2();
|
|
371
|
+
}
|
|
372
|
+
catch { }
|
|
373
|
+
// The link has been consumed; drop it so a future flow regenerates one.
|
|
374
|
+
clearCachedUrl(activeEnv);
|
|
375
|
+
try {
|
|
376
|
+
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
|
|
489
377
|
method: "GET",
|
|
490
378
|
credentials: "include",
|
|
491
379
|
});
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
return;
|
|
495
|
-
setStatusInfo(st);
|
|
496
|
-
const isConnected = st?.status === "connected" && st?.seller_client_id_present === true;
|
|
497
|
-
if (isConnected) {
|
|
498
|
-
setConnState("connected");
|
|
499
|
-
setShowManual(false);
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
catch (e) {
|
|
504
|
-
console.error(e);
|
|
505
|
-
}
|
|
506
|
-
if (cancelled || runId !== currentRunId.current)
|
|
507
|
-
return;
|
|
508
|
-
const cached = readCachedUrl(env);
|
|
509
|
-
if (cached) {
|
|
510
|
-
setFinalUrl(cached);
|
|
511
|
-
setConnState("ready");
|
|
512
|
-
}
|
|
513
|
-
else {
|
|
514
|
-
fetchFreshLink(runId, env);
|
|
380
|
+
const refreshedStatus = await statusRes.json().catch(() => ({}));
|
|
381
|
+
setStatusInfo(refreshedStatus || null);
|
|
515
382
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
383
|
+
catch { }
|
|
384
|
+
setConnState("connected");
|
|
385
|
+
setShowManual(false);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
completedRef.current = false;
|
|
389
|
+
console.error(e);
|
|
390
|
+
setConnState("error");
|
|
391
|
+
setError(e?.message || "Exchange failed while saving credentials.");
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
setOnboardingInProgress(false);
|
|
395
|
+
}
|
|
396
|
+
}, []);
|
|
523
397
|
(0, react_1.useLayoutEffect)(() => {
|
|
524
398
|
window.onboardingCallback = (authCode, sharedId) => {
|
|
525
399
|
completeOnboarding(authCode, sharedId);
|
|
@@ -528,12 +402,6 @@ function PayPalConnectionPage() {
|
|
|
528
402
|
window.onboardingCallback = undefined;
|
|
529
403
|
};
|
|
530
404
|
}, [completeOnboarding]);
|
|
531
|
-
// Clean up the popup poll on unmount.
|
|
532
|
-
(0, react_1.useEffect)(() => {
|
|
533
|
-
return () => {
|
|
534
|
-
stopPopupPoll();
|
|
535
|
-
};
|
|
536
|
-
}, [stopPopupPoll]);
|
|
537
405
|
(0, react_1.useLayoutEffect)(() => {
|
|
538
406
|
const el = ppBtnMeasureRef.current;
|
|
539
407
|
if (!el)
|
|
@@ -559,51 +427,6 @@ function PayPalConnectionPage() {
|
|
|
559
427
|
window.removeEventListener("resize", update);
|
|
560
428
|
};
|
|
561
429
|
}, [connState, env, finalUrl]);
|
|
562
|
-
// Capture-phase click handler — the single, reliable opener.
|
|
563
|
-
//
|
|
564
|
-
// It runs on `document` in the CAPTURE phase, i.e. BEFORE the event reaches
|
|
565
|
-
// the anchor where partner.js binds its own (target-phase) handler. That
|
|
566
|
-
// ordering is the whole point:
|
|
567
|
-
// 1. We synchronously open the mini-browser popup here, inside the user
|
|
568
|
-
// gesture, so the browser always renders a real popup (never a blocked
|
|
569
|
-
// window, never a new tab).
|
|
570
|
-
// 2. We do NOT stop propagation, so partner.js's handler still runs a beat
|
|
571
|
-
// later. Since it opens with the same window name (POPUP_NAME), it adopts
|
|
572
|
-
// the window we just opened instead of asynchronously spawning its own
|
|
573
|
-
// (which the browser was blocking — "multiple popups ... lack of user
|
|
574
|
-
// activation") — and it still wires its postMessage->onboardingCallback
|
|
575
|
-
// bridge that auto-closes the popup and saves credentials.
|
|
576
|
-
// 3. We always preventDefault so the anchor's href can never open a tab.
|
|
577
|
-
//
|
|
578
|
-
// Completion is handled by partner.js's bridge AND by our own postMessage
|
|
579
|
-
// listener (see below), funnelling through one idempotent completeOnboarding.
|
|
580
|
-
(0, react_1.useEffect)(() => {
|
|
581
|
-
const onCaptureClick = (e) => {
|
|
582
|
-
const btn = paypalButtonRef.current;
|
|
583
|
-
if (!btn)
|
|
584
|
-
return;
|
|
585
|
-
const target = e.target;
|
|
586
|
-
if (!target || !btn.contains(target))
|
|
587
|
-
return;
|
|
588
|
-
e.preventDefault();
|
|
589
|
-
if (connStateRef.current !== "ready" ||
|
|
590
|
-
!finalUrlRef.current ||
|
|
591
|
-
inProgressRef.current) {
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
completedRef.current = false;
|
|
595
|
-
const popup = openOnboardingPopup(finalUrlRef.current);
|
|
596
|
-
setPopupBlocked(!popup);
|
|
597
|
-
};
|
|
598
|
-
document.addEventListener("click", onCaptureClick, true);
|
|
599
|
-
return () => document.removeEventListener("click", onCaptureClick, true);
|
|
600
|
-
}, [openOnboardingPopup]);
|
|
601
|
-
// Defensive only: the capture-phase listener above already opened the popup
|
|
602
|
-
// and prevented the default. If for some reason it didn't run, make sure the
|
|
603
|
-
// native target still can't open a tab.
|
|
604
|
-
const handleConnectClick = (e) => {
|
|
605
|
-
e.preventDefault();
|
|
606
|
-
};
|
|
607
430
|
const handleSaveManual = async () => {
|
|
608
431
|
if (!canSaveManual || onboardingInProgress)
|
|
609
432
|
return;
|
|
@@ -668,9 +491,10 @@ function PayPalConnectionPage() {
|
|
|
668
491
|
}
|
|
669
492
|
clearCachedUrl(env);
|
|
670
493
|
completedRef.current = false;
|
|
494
|
+
// Generate a fresh link for the now-disconnected environment and reload so
|
|
495
|
+
// the onboarding button is ready again.
|
|
671
496
|
currentRunId.current = ++runIdRef.current;
|
|
672
|
-
|
|
673
|
-
fetchFreshLink(runId, env);
|
|
497
|
+
await generateLinkAndReload(env, currentRunId.current);
|
|
674
498
|
}
|
|
675
499
|
catch (e) {
|
|
676
500
|
console.error(e);
|
|
@@ -683,15 +507,13 @@ function PayPalConnectionPage() {
|
|
|
683
507
|
};
|
|
684
508
|
const handleEnvChange = async (e) => {
|
|
685
509
|
const next = e.target.value;
|
|
510
|
+
if (next === env || onboardingInProgress)
|
|
511
|
+
return;
|
|
686
512
|
completedRef.current = false;
|
|
687
|
-
setPopupBlocked(false);
|
|
688
|
-
// Drop both cached URLs so neither environment can serve a stale link.
|
|
689
513
|
clearCachedUrl();
|
|
690
|
-
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
// POST winning a race.
|
|
694
|
-
setEnv(next);
|
|
514
|
+
setConnState("loading");
|
|
515
|
+
// Persist the environment BEFORE switching local state so the post-reload
|
|
516
|
+
// GET /environment returns the same value the cache was keyed under.
|
|
695
517
|
try {
|
|
696
518
|
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
697
519
|
method: "POST",
|
|
@@ -701,17 +523,24 @@ function PayPalConnectionPage() {
|
|
|
701
523
|
});
|
|
702
524
|
}
|
|
703
525
|
catch { }
|
|
526
|
+
setEnv(next);
|
|
704
527
|
};
|
|
705
528
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "p-6", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-6", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex items-start justify-between gap-4", children: (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("h1", { className: "text-xl font-semibold text-ui-fg-base", children: "PayPal Gateway By Easy Payment" }) }) }), (0, jsx_runtime_1.jsx)(Tabs_1.default, {}), (0, jsx_runtime_1.jsx)("div", { className: "rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm", children: (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-1 gap-y-6 p-4 md:grid-cols-[260px_1fr] md:items-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium pt-2", children: "Environment" }), (0, jsx_runtime_1.jsx)("div", { className: "max-w-xl", children: (0, jsx_runtime_1.jsxs)("select", { value: env, onChange: handleEnvChange, disabled: onboardingInProgress, className: "w-full rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm", children: [(0, jsx_runtime_1.jsx)("option", { value: "sandbox", children: "Sandbox (Test Mode)" }), (0, jsx_runtime_1.jsx)("option", { value: "live", children: "Live (Production)" })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium pt-2", children: env === "sandbox" ? "Connect to PayPal (Sandbox)" : "Connect to PayPal" }), (0, jsx_runtime_1.jsx)("div", { className: "max-w-xl", children: connState === "connected" ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200", children: "\u2705 Successfully connected to PayPal!" }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle", children: [(0, jsx_runtime_1.jsx)("div", { className: "font-medium text-ui-fg-base", children: "Connected PayPal account" }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-1", children: ["Email:", " ", (0, jsx_runtime_1.jsx)("span", { className: "font-mono text-ui-fg-base", children: statusInfo?.seller_email || "Unavailable" })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "mt-3 flex items-center gap-2", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleDisconnect, disabled: onboardingInProgress, className: "transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5", children: "Disconnect" }) })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { ref: initLoaderRef, id: "init-loader", className: `status-msg mb-4 ${connState !== "loading" ? "hidden" : "block"}`, children: [(0, jsx_runtime_1.jsx)("div", { className: "loader inline-block align-middle mr-2" }), (0, jsx_runtime_1.jsx)("span", { id: "loader-text", className: "text-sm", children: onboardingInProgress
|
|
706
529
|
? "Configuring connection to PayPal…"
|
|
707
530
|
: "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
|
|
708
|
-
paypalButtonRef.current = node;
|
|
709
531
|
ppBtnMeasureRef.current = node;
|
|
710
|
-
}, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback",
|
|
711
|
-
cursor: onboardingInProgress
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
532
|
+
}, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback", "aria-disabled": onboardingInProgress || !partnerReady, className: "transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none no-underline shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5", style: {
|
|
533
|
+
cursor: onboardingInProgress || !partnerReady
|
|
534
|
+
? "not-allowed"
|
|
535
|
+
: "pointer",
|
|
536
|
+
opacity: onboardingInProgress || !partnerReady ? 0.6 : 1,
|
|
537
|
+
// Keep the anchor mounted (partner.js must find it to bind)
|
|
538
|
+
// but un-clickable until partner.js has taken over the
|
|
539
|
+
// click — so it can never open a tab via the native href.
|
|
540
|
+
pointerEvents: onboardingInProgress || !partnerReady ? "none" : "auto",
|
|
541
|
+
}, children: partnerReady || onboardingInProgress
|
|
542
|
+
? "Connect to PayPal"
|
|
543
|
+
: "Preparing PayPal…" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
|
|
715
544
|
width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
|
|
716
545
|
marginTop: "20px",
|
|
717
546
|
marginBottom: "10px",
|