@easypayment/medusa-payment-paypal 0.9.17 → 0.9.19
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 +326 -130
- package/.medusa/server/src/admin/index.mjs +326 -130
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +4 -1
- 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 +434 -113
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js +4 -0
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js.map +1 -1
- package/.medusa/server/src/api/middlewares.d.ts.map +1 -1
- package/.medusa/server/src/api/middlewares.js +9 -0
- package/.medusa/server/src/api/middlewares.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.js +13 -8
- package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.js +54 -31
- package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts +32 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js +65 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js.map +1 -0
- package/.medusa/server/src/modules/paypal/service.d.ts +13 -1
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +19 -4
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +2 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +82 -26
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts +28 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.js +58 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.js.map +1 -0
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +471 -127
- package/src/api/admin/paypal/onboarding-link/route.ts +6 -0
- package/src/api/middlewares.ts +9 -0
- package/src/api/store/paypal/webhook/route.ts +22 -8
- package/src/modules/paypal/payment-provider/service.ts +73 -29
- package/src/modules/paypal/payment-provider/status-utils.ts +59 -0
- package/src/modules/paypal/service.ts +24 -5
- package/src/modules/paypal/utils/secret-crypto.ts +90 -27
- package/src/modules/paypal/utils/webhook-verify.ts +61 -0
|
@@ -17,40 +17,137 @@ 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
|
-
|
|
20
|
+
// The mini-browser popup is opened by us (see openOnboardingPopup). We give it a
|
|
21
|
+
// stable window name so that, even if PayPal's partner.js *also* intercepts the
|
|
22
|
+
// click, window.open() with the same name re-targets the one window instead of
|
|
23
|
+
// spawning a second one.
|
|
24
|
+
const POPUP_NAME = "PPMiniBrowser";
|
|
25
|
+
const CACHE_PREFIX = "pp_onboard_cache";
|
|
21
26
|
const CACHE_EXPIRY = 10 * 60 * 1000;
|
|
22
27
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
|
|
23
28
|
const STATUS_ENDPOINT = "/admin/paypal/status";
|
|
24
29
|
const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials";
|
|
25
30
|
const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect";
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment";
|
|
32
|
+
// Onboarding URLs are environment-specific and short-lived, so the cache must be
|
|
33
|
+
// keyed per environment — a single shared key let a stale sandbox URL leak into
|
|
34
|
+
// the live flow (and vice-versa) after a reload.
|
|
35
|
+
const cacheKeyFor = (env) => `${CACHE_PREFIX}_${env}`;
|
|
36
|
+
function readCachedUrl(env) {
|
|
37
|
+
if (typeof window === "undefined")
|
|
38
|
+
return null;
|
|
28
39
|
try {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
const raw = localStorage.getItem(cacheKeyFor(env));
|
|
41
|
+
if (!raw)
|
|
42
|
+
return null;
|
|
43
|
+
const data = JSON.parse(raw);
|
|
44
|
+
if (data &&
|
|
45
|
+
typeof data.url === "string" &&
|
|
46
|
+
Date.now() - (Number(data.ts) || 0) < CACHE_EXPIRY) {
|
|
47
|
+
return data.url;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* ignore malformed cache */
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function writeCachedUrl(env, url) {
|
|
56
|
+
try {
|
|
57
|
+
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* ignore quota / disabled storage */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function clearCachedUrl(env) {
|
|
64
|
+
try {
|
|
65
|
+
if (env) {
|
|
66
|
+
localStorage.removeItem(cacheKeyFor(env));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
localStorage.removeItem(cacheKeyFor("sandbox"));
|
|
70
|
+
localStorage.removeItem(cacheKeyFor("live"));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// PayPal delivers the seamless-onboarding result by posting a message to
|
|
78
|
+
// window.opener. We validate the sender is actually a PayPal origin before
|
|
79
|
+
// trusting authCode/sharedId.
|
|
80
|
+
function isPayPalOrigin(origin) {
|
|
81
|
+
try {
|
|
82
|
+
const host = new URL(origin).hostname.toLowerCase();
|
|
83
|
+
return (host === "www.paypal.com" ||
|
|
84
|
+
host === "www.sandbox.paypal.com" ||
|
|
85
|
+
host.endsWith(".paypal.com") ||
|
|
86
|
+
host.endsWith(".paypalobjects.com"));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// The completion message shape is not strongly documented, so extract
|
|
93
|
+
// authCode/sharedId defensively from objects, JSON strings, or query strings.
|
|
94
|
+
// We only ever act when BOTH values are present, which keeps the many unrelated
|
|
95
|
+
// intermediate postMessages PayPal emits from triggering a false completion.
|
|
96
|
+
function extractAuth(data) {
|
|
97
|
+
if (!data)
|
|
98
|
+
return {};
|
|
99
|
+
if (typeof data === "object") {
|
|
100
|
+
const obj = data;
|
|
101
|
+
const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode;
|
|
102
|
+
const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid;
|
|
103
|
+
if (authCode && sharedId) {
|
|
104
|
+
return { authCode: String(authCode), sharedId: String(sharedId) };
|
|
105
|
+
}
|
|
106
|
+
for (const key of ["data", "payload", "message", "detail", "body"]) {
|
|
107
|
+
if (obj[key] && typeof obj[key] === "object") {
|
|
108
|
+
const inner = extractAuth(obj[key]);
|
|
109
|
+
if (inner.authCode && inner.sharedId)
|
|
110
|
+
return inner;
|
|
34
111
|
}
|
|
35
112
|
}
|
|
113
|
+
return {};
|
|
36
114
|
}
|
|
37
|
-
|
|
38
|
-
|
|
115
|
+
if (typeof data === "string") {
|
|
116
|
+
const s = data.trim();
|
|
117
|
+
if (!s)
|
|
118
|
+
return {};
|
|
119
|
+
if (s.startsWith("{")) {
|
|
120
|
+
try {
|
|
121
|
+
return extractAuth(JSON.parse(s));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* not JSON */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
|
|
128
|
+
try {
|
|
129
|
+
const sp = new URLSearchParams(s.replace(/^[?#]/, ""));
|
|
130
|
+
const authCode = sp.get("authCode") || sp.get("auth_code") || undefined;
|
|
131
|
+
const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined;
|
|
132
|
+
if (authCode && sharedId)
|
|
133
|
+
return { authCode, sharedId };
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* not a query string */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
39
139
|
}
|
|
140
|
+
return {};
|
|
40
141
|
}
|
|
41
142
|
function PayPalConnectionPage() {
|
|
42
143
|
const [env, setEnv] = (0, react_1.useState)("live");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const v = d?.environment === "sandbox" ? "sandbox" : "live";
|
|
48
|
-
setEnv(v);
|
|
49
|
-
})
|
|
50
|
-
.catch(() => { });
|
|
51
|
-
}, []);
|
|
144
|
+
// We must not generate/fetch an onboarding link until we know which
|
|
145
|
+
// environment the server is actually on, otherwise the first fetch races the
|
|
146
|
+
// GET /environment response and can target the wrong environment.
|
|
147
|
+
const [envReady, setEnvReady] = (0, react_1.useState)(false);
|
|
52
148
|
const [connState, setConnState] = (0, react_1.useState)("loading");
|
|
53
149
|
const [error, setError] = (0, react_1.useState)(null);
|
|
150
|
+
const [popupBlocked, setPopupBlocked] = (0, react_1.useState)(false);
|
|
54
151
|
const [finalUrl, setFinalUrl] = (0, react_1.useState)("");
|
|
55
152
|
const [showManual, setShowManual] = (0, react_1.useState)(false);
|
|
56
153
|
const [clientId, setClientId] = (0, react_1.useState)("");
|
|
@@ -62,6 +159,21 @@ function PayPalConnectionPage() {
|
|
|
62
159
|
const errorLogRef = (0, react_1.useRef)(null);
|
|
63
160
|
const runIdRef = (0, react_1.useRef)(0);
|
|
64
161
|
const currentRunId = (0, react_1.useRef)(0);
|
|
162
|
+
// Live mirrors of state so the document-level capture click listener (set up
|
|
163
|
+
// once) and the stable completion callback always see current values.
|
|
164
|
+
const finalUrlRef = (0, react_1.useRef)(finalUrl);
|
|
165
|
+
finalUrlRef.current = finalUrl;
|
|
166
|
+
const connStateRef = (0, react_1.useRef)(connState);
|
|
167
|
+
connStateRef.current = connState;
|
|
168
|
+
const inProgressRef = (0, react_1.useRef)(onboardingInProgress);
|
|
169
|
+
inProgressRef.current = onboardingInProgress;
|
|
170
|
+
const envRef = (0, react_1.useRef)(env);
|
|
171
|
+
envRef.current = env;
|
|
172
|
+
const popupRef = (0, react_1.useRef)(null);
|
|
173
|
+
const popupPollRef = (0, react_1.useRef)(null);
|
|
174
|
+
// Guards against the two redundant completion paths (our own message listener
|
|
175
|
+
// and partner.js's onboardingCallback bridge) both POSTing the same result.
|
|
176
|
+
const completedRef = (0, react_1.useRef)(false);
|
|
65
177
|
const ppBtnMeasureRef = (0, react_1.useRef)(null);
|
|
66
178
|
const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
|
|
67
179
|
const canSaveManual = (0, react_1.useMemo)(() => {
|
|
@@ -71,7 +183,148 @@ function PayPalConnectionPage() {
|
|
|
71
183
|
setConnState("error");
|
|
72
184
|
setError(msg);
|
|
73
185
|
}, []);
|
|
74
|
-
const
|
|
186
|
+
const stopPopupPoll = (0, react_1.useCallback)(() => {
|
|
187
|
+
if (popupPollRef.current) {
|
|
188
|
+
clearInterval(popupPollRef.current);
|
|
189
|
+
popupPollRef.current = null;
|
|
190
|
+
}
|
|
191
|
+
}, []);
|
|
192
|
+
// Open the PayPal mini-browser ourselves, synchronously, inside the click
|
|
193
|
+
// gesture. This is the crux of the fix: browsers only render window.open() as
|
|
194
|
+
// a real popup (instead of a tab — or blocking it) when it runs synchronously
|
|
195
|
+
// in a user gesture. Because finalUrl is already in state at click time, we
|
|
196
|
+
// never need an await before opening, and we never depend on winning a race
|
|
197
|
+
// with partner.js's asynchronous click binding.
|
|
198
|
+
const openOnboardingPopup = (0, react_1.useCallback)((url) => {
|
|
199
|
+
const w = 450;
|
|
200
|
+
const h = 600;
|
|
201
|
+
const baseLeft = typeof window.screenX === "number" ? window.screenX : window.screenLeft || 0;
|
|
202
|
+
const baseTop = typeof window.screenY === "number" ? window.screenY : window.screenTop || 0;
|
|
203
|
+
const outerW = window.outerWidth || document.documentElement.clientWidth || 1024;
|
|
204
|
+
const outerH = window.outerHeight || document.documentElement.clientHeight || 768;
|
|
205
|
+
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2));
|
|
206
|
+
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2));
|
|
207
|
+
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`;
|
|
208
|
+
let popup = null;
|
|
209
|
+
try {
|
|
210
|
+
popup = window.open(url, POPUP_NAME, features);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
popup = null;
|
|
214
|
+
}
|
|
215
|
+
if (popup) {
|
|
216
|
+
popupRef.current = popup;
|
|
217
|
+
try {
|
|
218
|
+
popup.focus();
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* ignore */
|
|
222
|
+
}
|
|
223
|
+
stopPopupPoll();
|
|
224
|
+
popupPollRef.current = setInterval(() => {
|
|
225
|
+
if (!popupRef.current || popupRef.current.closed) {
|
|
226
|
+
stopPopupPoll();
|
|
227
|
+
}
|
|
228
|
+
}, 1000);
|
|
229
|
+
}
|
|
230
|
+
return popup;
|
|
231
|
+
}, [stopPopupPoll]);
|
|
232
|
+
// Single entry point for "Connect to PayPal". Hard-guards on a real, ready
|
|
233
|
+
// URL so a half-loaded button can never open anything.
|
|
234
|
+
const openConnect = (0, react_1.useCallback)(() => {
|
|
235
|
+
if (connStateRef.current !== "ready" ||
|
|
236
|
+
!finalUrlRef.current ||
|
|
237
|
+
inProgressRef.current) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
completedRef.current = false;
|
|
241
|
+
const popup = openOnboardingPopup(finalUrlRef.current);
|
|
242
|
+
if (!popup) {
|
|
243
|
+
setPopupBlocked(true);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
setPopupBlocked(false);
|
|
247
|
+
}, [openOnboardingPopup]);
|
|
248
|
+
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
249
|
+
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
250
|
+
// listener; completedRef makes it idempotent so we only POST once.
|
|
251
|
+
const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
|
|
252
|
+
if (!authCode || !sharedId)
|
|
253
|
+
return;
|
|
254
|
+
if (completedRef.current)
|
|
255
|
+
return;
|
|
256
|
+
completedRef.current = true;
|
|
257
|
+
try {
|
|
258
|
+
window.onbeforeunload = null;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* ignore */
|
|
262
|
+
}
|
|
263
|
+
const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
|
|
264
|
+
setOnboardingInProgress(true);
|
|
265
|
+
setConnState("loading");
|
|
266
|
+
setError(null);
|
|
267
|
+
setPopupBlocked(false);
|
|
268
|
+
try {
|
|
269
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { "content-type": "application/json" },
|
|
272
|
+
credentials: "include",
|
|
273
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
const txt = await res.text().catch(() => "");
|
|
277
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`);
|
|
278
|
+
}
|
|
279
|
+
// Close the popup we opened, plus any partner.js-managed flow.
|
|
280
|
+
try {
|
|
281
|
+
popupRef.current?.close();
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
/* ignore */
|
|
285
|
+
}
|
|
286
|
+
stopPopupPoll();
|
|
287
|
+
try {
|
|
288
|
+
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
|
|
289
|
+
if (typeof close1 === "function")
|
|
290
|
+
close1();
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* ignore */
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
297
|
+
window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
|
|
298
|
+
if (typeof close2 === "function")
|
|
299
|
+
close2();
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
/* ignore */
|
|
303
|
+
}
|
|
304
|
+
clearCachedUrl(activeEnv);
|
|
305
|
+
try {
|
|
306
|
+
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, { method: "GET", credentials: "include" });
|
|
307
|
+
const refreshedStatus = await statusRes.json().catch(() => ({}));
|
|
308
|
+
setStatusInfo(refreshedStatus || null);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
/* ignore — still consider it connected below */
|
|
312
|
+
}
|
|
313
|
+
setConnState("connected");
|
|
314
|
+
setShowManual(false);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
// Allow a retry after a failed exchange.
|
|
318
|
+
completedRef.current = false;
|
|
319
|
+
console.error(e);
|
|
320
|
+
setConnState("error");
|
|
321
|
+
setError(e?.message || "Exchange failed while saving credentials.");
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
setOnboardingInProgress(false);
|
|
325
|
+
}
|
|
326
|
+
}, [stopPopupPoll]);
|
|
327
|
+
const fetchFreshLink = (0, react_1.useCallback)((runId, targetEnv) => {
|
|
75
328
|
if (initLoaderRef.current) {
|
|
76
329
|
const loaderText = initLoaderRef.current.querySelector("#loader-text");
|
|
77
330
|
if (loaderText)
|
|
@@ -83,6 +336,9 @@ function PayPalConnectionPage() {
|
|
|
83
336
|
credentials: "include",
|
|
84
337
|
body: JSON.stringify({
|
|
85
338
|
products: ["PPCP"],
|
|
339
|
+
// Generate the link for the explicitly selected environment so it can
|
|
340
|
+
// never depend on a racing POST /environment having landed yet.
|
|
341
|
+
environment: targetEnv,
|
|
86
342
|
}),
|
|
87
343
|
})
|
|
88
344
|
.then((r) => {
|
|
@@ -99,7 +355,7 @@ function PayPalConnectionPage() {
|
|
|
99
355
|
return;
|
|
100
356
|
}
|
|
101
357
|
const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
|
|
102
|
-
|
|
358
|
+
writeCachedUrl(targetEnv, url);
|
|
103
359
|
setFinalUrl(url);
|
|
104
360
|
setConnState("ready");
|
|
105
361
|
})
|
|
@@ -108,36 +364,133 @@ function PayPalConnectionPage() {
|
|
|
108
364
|
return;
|
|
109
365
|
showError("Unable to connect to service.");
|
|
110
366
|
});
|
|
111
|
-
}, [
|
|
367
|
+
}, [showError]);
|
|
368
|
+
// Resolve the server's current environment before doing anything else.
|
|
369
|
+
(0, react_1.useEffect)(() => {
|
|
370
|
+
let alive = true;
|
|
371
|
+
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
372
|
+
.then((r) => r.json())
|
|
373
|
+
.then((d) => {
|
|
374
|
+
if (!alive)
|
|
375
|
+
return;
|
|
376
|
+
setEnv(d?.environment === "sandbox" ? "sandbox" : "live");
|
|
377
|
+
})
|
|
378
|
+
.catch(() => { })
|
|
379
|
+
.finally(() => {
|
|
380
|
+
if (alive)
|
|
381
|
+
setEnvReady(true);
|
|
382
|
+
});
|
|
383
|
+
return () => {
|
|
384
|
+
alive = false;
|
|
385
|
+
};
|
|
386
|
+
}, []);
|
|
387
|
+
// partner.js is loaded ONLY for its postMessage->callback bridge
|
|
388
|
+
// (window.onboardingCallback), as a redundant path next to our own message
|
|
389
|
+
// listener below. It is NO LONGER responsible for opening the window — we do
|
|
390
|
+
// that synchronously in the click handler. We still init it so its message
|
|
391
|
+
// bridge registers inside this React SPA (its own DOMContentLoaded hook fired
|
|
392
|
+
// long before this route mounted). If partner.js also intercepts the click,
|
|
393
|
+
// the shared POPUP_NAME means it re-targets our single popup rather than
|
|
394
|
+
// opening a second window.
|
|
112
395
|
(0, react_1.useEffect)(() => {
|
|
113
396
|
if (connState !== "ready" || !finalUrl)
|
|
114
397
|
return;
|
|
115
398
|
const scriptUrl = PARTNER_JS_URLS[env];
|
|
399
|
+
let cancelled = false;
|
|
400
|
+
let pollTimer;
|
|
401
|
+
const initPartner = (attempt = 0) => {
|
|
402
|
+
if (cancelled)
|
|
403
|
+
return;
|
|
404
|
+
const signup = window.PAYPAL?.apps?.Signup;
|
|
405
|
+
const target = signup?.miniBrowser && typeof signup.miniBrowser.init === "function"
|
|
406
|
+
? signup.miniBrowser
|
|
407
|
+
: signup?.MiniBrowser && typeof signup.MiniBrowser.init === "function"
|
|
408
|
+
? signup.MiniBrowser
|
|
409
|
+
: null;
|
|
410
|
+
if (target?.init) {
|
|
411
|
+
try {
|
|
412
|
+
target.init();
|
|
413
|
+
}
|
|
414
|
+
catch (e) {
|
|
415
|
+
console.error("[paypal] partner.js init failed:", e);
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (typeof signup?.render === "function") {
|
|
420
|
+
try {
|
|
421
|
+
signup.render();
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
console.error("[paypal] partner.js render failed:", e);
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (attempt < 40) {
|
|
429
|
+
pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
434
|
+
}
|
|
435
|
+
catch { }
|
|
436
|
+
};
|
|
437
|
+
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
438
|
+
// the current environment (sandbox vs live partner.js differ).
|
|
116
439
|
const existingScript = document.getElementById("paypal-partner-js");
|
|
117
440
|
if (existingScript) {
|
|
118
441
|
existingScript.parentNode?.removeChild(existingScript);
|
|
119
442
|
}
|
|
120
443
|
if (window.PAYPAL?.apps?.Signup) {
|
|
121
|
-
|
|
444
|
+
try {
|
|
445
|
+
delete window.PAYPAL.apps.Signup;
|
|
446
|
+
}
|
|
447
|
+
catch { }
|
|
122
448
|
}
|
|
123
449
|
const ppScript = document.createElement("script");
|
|
124
450
|
ppScript.id = "paypal-partner-js";
|
|
125
451
|
ppScript.src = scriptUrl;
|
|
126
452
|
ppScript.async = true;
|
|
453
|
+
ppScript.onload = () => initPartner();
|
|
127
454
|
document.body.appendChild(ppScript);
|
|
128
455
|
return () => {
|
|
456
|
+
cancelled = true;
|
|
457
|
+
if (pollTimer) {
|
|
458
|
+
clearTimeout(pollTimer);
|
|
459
|
+
}
|
|
129
460
|
if (ppScript.parentNode) {
|
|
130
461
|
ppScript.parentNode.removeChild(ppScript);
|
|
131
462
|
}
|
|
132
463
|
};
|
|
133
464
|
}, [connState, finalUrl, env]);
|
|
465
|
+
// Primary completion path: receive PayPal's seamless-onboarding postMessage
|
|
466
|
+
// ourselves. Independent of partner.js, so it works even when we opened the
|
|
467
|
+
// popup directly.
|
|
468
|
+
(0, react_1.useEffect)(() => {
|
|
469
|
+
const onMessage = (ev) => {
|
|
470
|
+
if (!isPayPalOrigin(ev.origin))
|
|
471
|
+
return;
|
|
472
|
+
const { authCode, sharedId } = extractAuth(ev.data);
|
|
473
|
+
if (authCode && sharedId) {
|
|
474
|
+
completeOnboarding(authCode, sharedId);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
window.addEventListener("message", onMessage);
|
|
478
|
+
return () => window.removeEventListener("message", onMessage);
|
|
479
|
+
}, [completeOnboarding]);
|
|
480
|
+
// Status check + link generation. Gated on envReady so we always use the
|
|
481
|
+
// correct environment. runId guards against a stale response from a previous
|
|
482
|
+
// environment overwriting the current one.
|
|
134
483
|
(0, react_1.useEffect)(() => {
|
|
484
|
+
if (!envReady)
|
|
485
|
+
return;
|
|
135
486
|
currentRunId.current = ++runIdRef.current;
|
|
136
487
|
const runId = currentRunId.current;
|
|
137
488
|
let cancelled = false;
|
|
489
|
+
completedRef.current = false;
|
|
138
490
|
const run = async () => {
|
|
139
491
|
setConnState("loading");
|
|
140
492
|
setError(null);
|
|
493
|
+
setPopupBlocked(false);
|
|
141
494
|
setFinalUrl("");
|
|
142
495
|
try {
|
|
143
496
|
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
@@ -158,89 +511,57 @@ function PayPalConnectionPage() {
|
|
|
158
511
|
catch (e) {
|
|
159
512
|
console.error(e);
|
|
160
513
|
}
|
|
161
|
-
if (
|
|
162
|
-
|
|
514
|
+
if (cancelled || runId !== currentRunId.current)
|
|
515
|
+
return;
|
|
516
|
+
const cached = readCachedUrl(env);
|
|
517
|
+
if (cached) {
|
|
518
|
+
setFinalUrl(cached);
|
|
163
519
|
setConnState("ready");
|
|
164
520
|
}
|
|
165
521
|
else {
|
|
166
|
-
fetchFreshLink(runId);
|
|
522
|
+
fetchFreshLink(runId, env);
|
|
167
523
|
}
|
|
168
524
|
};
|
|
169
525
|
run();
|
|
170
526
|
return () => {
|
|
171
527
|
cancelled = true;
|
|
172
|
-
currentRunId.current = 0;
|
|
173
528
|
};
|
|
174
|
-
}, [env, fetchFreshLink]);
|
|
529
|
+
}, [env, envReady, fetchFreshLink]);
|
|
530
|
+
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
175
531
|
(0, react_1.useLayoutEffect)(() => {
|
|
176
|
-
window.onboardingCallback =
|
|
177
|
-
|
|
178
|
-
window.onbeforeunload = null;
|
|
179
|
-
}
|
|
180
|
-
catch { }
|
|
181
|
-
setOnboardingInProgress(true);
|
|
182
|
-
setConnState("loading");
|
|
183
|
-
setError(null);
|
|
184
|
-
const payload = {
|
|
185
|
-
authCode,
|
|
186
|
-
sharedId,
|
|
187
|
-
env: env === "sandbox" ? "sandbox" : "live",
|
|
188
|
-
};
|
|
189
|
-
try {
|
|
190
|
-
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
191
|
-
method: "POST",
|
|
192
|
-
headers: { "content-type": "application/json" },
|
|
193
|
-
credentials: "include",
|
|
194
|
-
body: JSON.stringify(payload),
|
|
195
|
-
});
|
|
196
|
-
if (!res.ok) {
|
|
197
|
-
const txt = await res.text().catch(() => "");
|
|
198
|
-
throw new Error(txt || `Onboarding exchange failed (${res.status})`);
|
|
199
|
-
}
|
|
200
|
-
try {
|
|
201
|
-
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
|
|
202
|
-
if (typeof close1 === "function")
|
|
203
|
-
close1();
|
|
204
|
-
}
|
|
205
|
-
catch { }
|
|
206
|
-
try {
|
|
207
|
-
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
208
|
-
window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
|
|
209
|
-
if (typeof close2 === "function")
|
|
210
|
-
close2();
|
|
211
|
-
}
|
|
212
|
-
catch { }
|
|
213
|
-
try {
|
|
214
|
-
localStorage.removeItem(CACHE_KEY);
|
|
215
|
-
}
|
|
216
|
-
catch { }
|
|
217
|
-
try {
|
|
218
|
-
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
219
|
-
method: "GET",
|
|
220
|
-
credentials: "include",
|
|
221
|
-
});
|
|
222
|
-
const refreshedStatus = await statusRes.json().catch(() => ({}));
|
|
223
|
-
setStatusInfo(refreshedStatus || null);
|
|
224
|
-
setConnState("connected");
|
|
225
|
-
setShowManual(false);
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
setConnState("connected");
|
|
229
|
-
setShowManual(false);
|
|
230
|
-
}
|
|
231
|
-
setOnboardingInProgress(false);
|
|
232
|
-
}
|
|
233
|
-
catch (e) {
|
|
234
|
-
console.error(e);
|
|
235
|
-
setConnState("error");
|
|
236
|
-
setError(e?.message || "Exchange failed while saving credentials.");
|
|
237
|
-
setOnboardingInProgress(false);
|
|
238
|
-
}
|
|
532
|
+
window.onboardingCallback = (authCode, sharedId) => {
|
|
533
|
+
completeOnboarding(authCode, sharedId);
|
|
239
534
|
};
|
|
240
535
|
return () => {
|
|
241
536
|
window.onboardingCallback = undefined;
|
|
242
537
|
};
|
|
243
|
-
}, [
|
|
538
|
+
}, [completeOnboarding]);
|
|
539
|
+
// Clean up the popup poll on unmount.
|
|
540
|
+
(0, react_1.useEffect)(() => {
|
|
541
|
+
return () => {
|
|
542
|
+
stopPopupPoll();
|
|
543
|
+
};
|
|
544
|
+
}, [stopPopupPoll]);
|
|
545
|
+
// Capture-phase click interceptor: guarantees WE own the popup. Running in the
|
|
546
|
+
// capture phase on the document means we execute before partner.js's own click
|
|
547
|
+
// handler, so we can suppress it (no duplicate window) and open the popup
|
|
548
|
+
// ourselves within the same user gesture. This is what makes the first click
|
|
549
|
+
// after a cold load reliably open a popup instead of a new tab.
|
|
550
|
+
(0, react_1.useEffect)(() => {
|
|
551
|
+
const onCaptureClick = (e) => {
|
|
552
|
+
const btn = paypalButtonRef.current;
|
|
553
|
+
if (!btn)
|
|
554
|
+
return;
|
|
555
|
+
const target = e.target;
|
|
556
|
+
if (!target || !btn.contains(target))
|
|
557
|
+
return;
|
|
558
|
+
e.preventDefault();
|
|
559
|
+
e.stopImmediatePropagation();
|
|
560
|
+
openConnect();
|
|
561
|
+
};
|
|
562
|
+
document.addEventListener("click", onCaptureClick, true);
|
|
563
|
+
return () => document.removeEventListener("click", onCaptureClick, true);
|
|
564
|
+
}, [openConnect]);
|
|
244
565
|
(0, react_1.useLayoutEffect)(() => {
|
|
245
566
|
const el = ppBtnMeasureRef.current;
|
|
246
567
|
if (!el)
|
|
@@ -266,10 +587,12 @@ function PayPalConnectionPage() {
|
|
|
266
587
|
window.removeEventListener("resize", update);
|
|
267
588
|
};
|
|
268
589
|
}, [connState, env, finalUrl]);
|
|
590
|
+
// Defensive fallback: the capture listener above normally handles the click
|
|
591
|
+
// and stops propagation before React sees it. If for any reason it didn't, we
|
|
592
|
+
// still prevent the native target from opening a tab and open the popup here.
|
|
269
593
|
const handleConnectClick = (e) => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
594
|
+
e.preventDefault();
|
|
595
|
+
openConnect();
|
|
273
596
|
};
|
|
274
597
|
const handleSaveManual = async () => {
|
|
275
598
|
if (!canSaveManual || onboardingInProgress)
|
|
@@ -300,10 +623,7 @@ function PayPalConnectionPage() {
|
|
|
300
623
|
setConnState("connected");
|
|
301
624
|
setStatusInfo(refreshedStatus || null);
|
|
302
625
|
setShowManual(false);
|
|
303
|
-
|
|
304
|
-
localStorage.removeItem(CACHE_KEY);
|
|
305
|
-
}
|
|
306
|
-
catch { }
|
|
626
|
+
clearCachedUrl(env);
|
|
307
627
|
}
|
|
308
628
|
catch (e) {
|
|
309
629
|
console.error(e);
|
|
@@ -336,13 +656,11 @@ function PayPalConnectionPage() {
|
|
|
336
656
|
const t = await res.text().catch(() => "");
|
|
337
657
|
throw new Error(t || `Disconnect failed (${res.status})`);
|
|
338
658
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
catch { }
|
|
659
|
+
clearCachedUrl(env);
|
|
660
|
+
completedRef.current = false;
|
|
343
661
|
currentRunId.current = ++runIdRef.current;
|
|
344
662
|
const runId = currentRunId.current;
|
|
345
|
-
fetchFreshLink(runId);
|
|
663
|
+
fetchFreshLink(runId, env);
|
|
346
664
|
}
|
|
347
665
|
catch (e) {
|
|
348
666
|
console.error(e);
|
|
@@ -355,10 +673,17 @@ function PayPalConnectionPage() {
|
|
|
355
673
|
};
|
|
356
674
|
const handleEnvChange = async (e) => {
|
|
357
675
|
const next = e.target.value;
|
|
676
|
+
completedRef.current = false;
|
|
677
|
+
setPopupBlocked(false);
|
|
678
|
+
// Drop both cached URLs so neither environment can serve a stale link.
|
|
679
|
+
clearCachedUrl();
|
|
680
|
+
// Update local state immediately (drives the status/link refetch with the
|
|
681
|
+
// explicit environment) and persist the choice. Because the link/status
|
|
682
|
+
// fetches are environment-explicit, correctness no longer depends on this
|
|
683
|
+
// POST winning a race.
|
|
358
684
|
setEnv(next);
|
|
359
|
-
cachedUrl = null;
|
|
360
685
|
try {
|
|
361
|
-
await fetch(
|
|
686
|
+
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
362
687
|
method: "POST",
|
|
363
688
|
headers: { "content-type": "application/json" },
|
|
364
689
|
credentials: "include",
|
|
@@ -366,21 +691,17 @@ function PayPalConnectionPage() {
|
|
|
366
691
|
});
|
|
367
692
|
}
|
|
368
693
|
catch { }
|
|
369
|
-
try {
|
|
370
|
-
localStorage.removeItem(CACHE_KEY);
|
|
371
|
-
}
|
|
372
|
-
catch { }
|
|
373
694
|
};
|
|
374
|
-
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.
|
|
695
|
+
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
|
|
375
696
|
? "Configuring connection to PayPal…"
|
|
376
697
|
: "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
|
|
377
698
|
paypalButtonRef.current = node;
|
|
378
699
|
ppBtnMeasureRef.current = node;
|
|
379
|
-
}, id: "paypal-button", target:
|
|
700
|
+
}, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback", onClick: handleConnectClick, 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: {
|
|
380
701
|
cursor: onboardingInProgress ? "not-allowed" : "pointer",
|
|
381
702
|
opacity: onboardingInProgress ? 0.6 : 1,
|
|
382
703
|
pointerEvents: onboardingInProgress ? "none" : "auto",
|
|
383
|
-
}, children: "Connect to PayPal" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
|
|
704
|
+
}, children: "Connect to PayPal" }), popupBlocked && ((0, jsx_runtime_1.jsx)("div", { className: "mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded", children: "Your browser blocked the PayPal popup. Please allow popups for this site, then click \u201CConnect to PayPal\u201D again." })), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
|
|
384
705
|
width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
|
|
385
706
|
marginTop: "20px",
|
|
386
707
|
marginBottom: "10px",
|