@easypayment/medusa-paypal 0.4.7 → 0.4.9

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.
Files changed (70) hide show
  1. package/.medusa/server/src/admin/index.js +7 -7
  2. package/.medusa/server/src/admin/index.mjs +7 -7
  3. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  4. package/.medusa/server/src/api/store/paypal/create-order/route.js +62 -139
  5. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  6. package/.medusa/server/src/modules/paypal/migrations/20260115120000_create_paypal_connection.js +22 -22
  7. package/.medusa/server/src/modules/paypal/migrations/20260123090000_create_paypal_settings.js +11 -11
  8. package/.medusa/server/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.js +18 -18
  9. package/.medusa/server/src/modules/paypal/migrations/20260401090000_create_paypal_metric.js +16 -16
  10. package/.medusa/server/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.js +20 -20
  11. package/.medusa/server/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.js +14 -14
  12. package/.medusa/server/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.js +15 -15
  13. package/README.md +142 -142
  14. package/package.json +75 -75
  15. package/src/admin/index.ts +7 -7
  16. package/src/admin/routes/settings/paypal/_components/Tabs.tsx +52 -52
  17. package/src/admin/routes/settings/paypal/_components/Toast.tsx +51 -51
  18. package/src/admin/routes/settings/paypal/additional-settings/page.tsx +200 -200
  19. package/src/admin/routes/settings/paypal/advanced-card-payments/page.tsx +183 -183
  20. package/src/admin/routes/settings/paypal/apple-pay/page.tsx +5 -5
  21. package/src/admin/routes/settings/paypal/connection/page.tsx +754 -754
  22. package/src/admin/routes/settings/paypal/google-pay/page.tsx +5 -5
  23. package/src/admin/routes/settings/paypal/pay-later-messaging/page.tsx +5 -5
  24. package/src/admin/routes/settings/paypal/paypal-settings/page.tsx +376 -376
  25. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +24 -24
  26. package/src/api/admin/paypal/disconnect/route.ts +8 -8
  27. package/src/api/admin/paypal/environment/route.ts +25 -25
  28. package/src/api/admin/paypal/onboard-complete/route.ts +44 -44
  29. package/src/api/admin/paypal/onboarding-link/route.ts +45 -45
  30. package/src/api/admin/paypal/onboarding-status/route.ts +18 -18
  31. package/src/api/admin/paypal/rotate-credentials/route.ts +8 -8
  32. package/src/api/admin/paypal/save-credentials/route.ts +14 -14
  33. package/src/api/admin/paypal/settings/route.ts +14 -14
  34. package/src/api/admin/paypal/status/route.ts +12 -12
  35. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +65 -65
  36. package/src/api/store/paypal/capture-order/route.ts +276 -276
  37. package/src/api/store/paypal/config/route.ts +102 -102
  38. package/src/api/store/paypal/create-order/route.ts +79 -176
  39. package/src/api/store/paypal/settings/route.ts +19 -19
  40. package/src/api/store/paypal/webhook/route.ts +246 -246
  41. package/src/api/store/paypal-complete/route.ts +75 -75
  42. package/src/jobs/paypal-reconcile.ts +112 -112
  43. package/src/jobs/paypal-webhook-retry.ts +85 -85
  44. package/src/modules/paypal/clients/paypal-seller.client.ts +59 -59
  45. package/src/modules/paypal/index.ts +8 -8
  46. package/src/modules/paypal/migrations/20260115120000_create_paypal_connection.ts +33 -33
  47. package/src/modules/paypal/migrations/20260123090000_create_paypal_settings.ts +22 -22
  48. package/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.ts +29 -29
  49. package/src/modules/paypal/migrations/20260401090000_create_paypal_metric.ts +27 -27
  50. package/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.ts +31 -31
  51. package/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.ts +25 -25
  52. package/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.ts +26 -26
  53. package/src/modules/paypal/migrations/20270101090000_set_paypal_environment_default_live.ts +11 -11
  54. package/src/modules/paypal/models/paypal_connection.ts +21 -21
  55. package/src/modules/paypal/models/paypal_metric.ts +9 -9
  56. package/src/modules/paypal/models/paypal_settings.ts +8 -8
  57. package/src/modules/paypal/models/paypal_webhook_event.ts +19 -19
  58. package/src/modules/paypal/payment-provider/README.md +22 -22
  59. package/src/modules/paypal/payment-provider/card-service.ts +760 -760
  60. package/src/modules/paypal/payment-provider/index.ts +19 -19
  61. package/src/modules/paypal/payment-provider/service.ts +1121 -1121
  62. package/src/modules/paypal/payment-provider/webhook-utils.ts +88 -88
  63. package/src/modules/paypal/service.ts +1247 -1247
  64. package/src/modules/paypal/types/config.ts +47 -47
  65. package/src/modules/paypal/utils/amounts.ts +41 -41
  66. package/src/modules/paypal/utils/crypto.ts +51 -51
  67. package/src/modules/paypal/utils/currencies.ts +84 -84
  68. package/src/modules/paypal/utils/paypal-auth.ts +32 -32
  69. package/src/modules/paypal/utils/provider-ids.ts +15 -15
  70. package/src/modules/paypal/webhook-processor.ts +215 -215
@@ -1,754 +1,754 @@
1
- import React, {
2
- useCallback,
3
- useEffect,
4
- useLayoutEffect,
5
- useMemo,
6
- useRef,
7
- useState,
8
- } from "react"
9
- import { defineRouteConfig } from "@medusajs/admin-sdk"
10
- import PayPalTabs from "../_components/Tabs"
11
-
12
- export const config = defineRouteConfig({
13
- label: "PayPal Connection",
14
- hide: true,
15
- })
16
-
17
- /* ------------------------------------------------------------------ */
18
- /* HIGH PRIORITY EXECUTION - RUNS BEFORE COMPONENT MOUNTS */
19
- /* ------------------------------------------------------------------ */
20
-
21
- // 1. Inject Preload Link IMMEDIATELY
22
- if (typeof window !== "undefined") {
23
- const preloadHref =
24
- "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js"
25
-
26
- const existingPreload = document.head.querySelector(
27
- `link[rel="preload"][href="${preloadHref}"]`
28
- )
29
- if (!existingPreload) {
30
- const preloadLink = document.createElement("link")
31
- preloadLink.rel = "preload"
32
- preloadLink.href = preloadHref
33
- preloadLink.as = "script"
34
- document.head.appendChild(preloadLink)
35
- }
36
-
37
- // 2. Inject PayPal Script IMMEDIATELY (before React renders)
38
- const existingScript = document.getElementById(
39
- "paypal-partner-js"
40
- ) as HTMLScriptElement | null
41
- if (!existingScript) {
42
- const ppScript = document.createElement("script")
43
- ppScript.id = "paypal-partner-js"
44
- ppScript.src = preloadHref
45
- ppScript.async = true
46
- document.head.appendChild(ppScript)
47
- }
48
- }
49
-
50
- declare global {
51
- interface Window {
52
- PAYPAL?: {
53
- apps?: {
54
- Signup?: {
55
- miniBrowser?: { init: () => void }
56
- // some partner.js builds expose MiniBrowser (capital M)
57
- MiniBrowser?: { closeFlow?: () => void }
58
- }
59
- }
60
- }
61
- onboardingCallback?: (authCode: string, sharedId: string) => void
62
- }
63
- }
64
-
65
- // Partner.js is now loaded lazily only when needed (removed global injection)
66
-
67
- const SERVICE_URL = "/admin/paypal/onboarding-link"
68
- const CACHE_KEY = "pp_onboard_cache"
69
- const RELOAD_KEY = "pp_onboard_reloaded_once"
70
- const CACHE_EXPIRY = 10 * 60 * 1000 // 10 minutes
71
-
72
- // ✅ backend endpoint to exchange authCode/sharedId (medusa)
73
- const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
74
- const STATUS_ENDPOINT = "/admin/paypal/status"
75
- const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials"
76
- const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect"
77
-
78
- // 3. Immediate Cache Check (runs before React component)
79
- let cachedUrl: string | null = null
80
- if (typeof window !== "undefined") {
81
- try {
82
- const cached = localStorage.getItem(CACHE_KEY)
83
- if (cached) {
84
- const data = JSON.parse(cached)
85
- if (new Date().getTime() - data.ts < CACHE_EXPIRY) {
86
- cachedUrl = data.url
87
- }
88
- }
89
- } catch (e) {
90
- console.error("Cache read error:", e)
91
- }
92
- }
93
-
94
- /* ------------------------------------------------------------------ */
95
- /* REACT COMPONENT */
96
- /* ------------------------------------------------------------------ */
97
-
98
- export default function PayPalConnectionPage() {
99
- const [env, setEnv] = useState<"sandbox" | "live">("live")
100
-
101
- // Sync initial environment from backend
102
- useEffect(() => {
103
- fetch("/admin/paypal/environment", { method: "GET" })
104
- .then((r) => r.json())
105
- .then((d) => {
106
- const v = d?.environment === "sandbox" ? "sandbox" : "live"
107
- setEnv(v)
108
- })
109
- .catch(() => {})
110
- }, [])
111
- const [connState, setConnState] = useState<
112
- "loading" | "ready" | "connected" | "error"
113
- >("loading")
114
- const [error, setError] = useState<string | null>(null)
115
- const [finalUrl, setFinalUrl] = useState<string>("")
116
- const [showManual, setShowManual] = useState(false)
117
- const [clientId, setClientId] = useState("")
118
- const [secret, setSecret] = useState("")
119
- const [statusInfo, setStatusInfo] = useState<{
120
- seller_client_id_masked?: string | null
121
- seller_client_secret_masked?: string | null
122
- seller_email?: string | null
123
- } | null>(null)
124
-
125
- // ✅ onboarding in progress (for disabling UI)
126
- const [onboardingInProgress, setOnboardingInProgress] = useState(false)
127
-
128
- const initLoaderRef = useRef<HTMLDivElement>(null)
129
- const paypalButtonRef = useRef<HTMLAnchorElement>(null)
130
- const errorLogRef = useRef<HTMLDivElement>(null)
131
- const runIdRef = useRef(0)
132
- const currentRunId = useRef(0)
133
-
134
- // Measure PayPal button width for OR centering (Woo-style)
135
- const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
136
- const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
137
-
138
- const canSaveManual = useMemo(() => {
139
- return clientId.trim().length > 0 && secret.trim().length > 0
140
- }, [clientId, secret])
141
-
142
- const maskValue = useCallback((value: string, visibleChars = 4) => {
143
- if (!value) return ""
144
- if (value.length <= visibleChars) {
145
- return "•".repeat(value.length)
146
- }
147
- return `${"•".repeat(Math.max(0, value.length - visibleChars))}${value.slice(
148
- -visibleChars
149
- )}`
150
- }, [])
151
-
152
- // Exact copy of fetchFreshLink from original JS (+ reload-once logic ONLY)
153
- const fetchFreshLink = useCallback(
154
- (runId: number) => {
155
- if (initLoaderRef.current) {
156
- const loaderText = initLoaderRef.current.querySelector("#loader-text")
157
- if (loaderText)
158
- loaderText.textContent = "Generating onboarding session..."
159
- }
160
-
161
- fetch(SERVICE_URL, {
162
- method: "POST",
163
- headers: { "content-type": "application/json" },
164
- body: JSON.stringify({
165
- products: ["PPCP"],
166
- }),
167
- })
168
- .then((r) => r.json())
169
- .then((data) => {
170
- if (runId !== currentRunId.current) return
171
-
172
- const href = data?.onboarding_url
173
- if (!href) {
174
- showError("Onboarding URL not returned.")
175
- return
176
- }
177
-
178
- const finalUrl =
179
- href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
180
-
181
- localStorage.setItem(
182
- CACHE_KEY,
183
- JSON.stringify({
184
- url: finalUrl,
185
- ts: Date.now(),
186
- })
187
- )
188
-
189
- if (!localStorage.getItem(RELOAD_KEY)) {
190
- localStorage.setItem(RELOAD_KEY, "1")
191
- window.location.reload()
192
- return
193
- }
194
-
195
- activatePayPal(finalUrl, runId)
196
- })
197
- .catch(() => {
198
- if (runId !== currentRunId.current) return
199
- showError("Unable to connect to service.")
200
- })
201
- },
202
- [env]
203
- )
204
-
205
- // Exact copy of showUI from original JS (safe: only init when button exists)
206
- const showUI = useCallback(() => {
207
- const btn = document.querySelector('[data-paypal-button="true"]')
208
- if (btn && window.PAYPAL?.apps?.Signup?.miniBrowser?.init) {
209
- window.PAYPAL.apps.Signup.miniBrowser.init()
210
- }
211
- setConnState("ready")
212
- }, [])
213
-
214
- // Exact copy of showError from original JS
215
- const showError = useCallback((msg: string) => {
216
- setConnState("error")
217
- setError(msg)
218
- }, [])
219
-
220
- // activatePayPal (keeps PayPal partner.js global injection; waits for PAYPAL then inits)
221
- const activatePayPal = useCallback(
222
- (url: string, runId: number) => {
223
- if (paypalButtonRef.current) {
224
- paypalButtonRef.current.href = url
225
- }
226
- setFinalUrl(url)
227
-
228
- const tryInit = () => {
229
- if (runId !== currentRunId.current) return
230
- if (window.PAYPAL?.apps?.Signup) {
231
- showUI()
232
- return
233
- }
234
- // wait briefly for partner.js to finish loading
235
- setTimeout(tryInit, 50)
236
- }
237
-
238
- tryInit()
239
- },
240
- [showUI]
241
- )
242
-
243
- // Initialize - runs on mount and env change
244
- useEffect(() => {
245
- currentRunId.current = ++runIdRef.current
246
- const runId = currentRunId.current
247
-
248
- let cancelled = false
249
-
250
- const run = async () => {
251
- setConnState("loading")
252
- setError(null)
253
- setFinalUrl("")
254
-
255
- // 1) If already connected in DB, don't ask to onboard again
256
- try {
257
- const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
258
- method: "GET",
259
- })
260
- const st = await r.json().catch(() => ({}))
261
-
262
- if (cancelled || runId !== currentRunId.current) return
263
-
264
- setStatusInfo(st)
265
-
266
- const isConnected =
267
- st?.status === "connected" && st?.seller_client_id_present === true
268
-
269
- if (isConnected) {
270
- setConnState("connected")
271
- setShowManual(false)
272
- return
273
- }
274
- } catch (e) {
275
- // ignore status errors; proceed with onboarding
276
- console.error(e)
277
- }
278
-
279
- // 2) Not connected -> continue onboarding flow
280
- if (cachedUrl) {
281
- console.log("Using prioritized cache...")
282
- activatePayPal(cachedUrl, runId)
283
- } else {
284
- fetchFreshLink(runId)
285
- }
286
- }
287
-
288
- run()
289
-
290
- return () => {
291
- cancelled = true
292
- currentRunId.current = 0
293
- }
294
- }, [env, fetchFreshLink, activatePayPal])
295
-
296
- // ✅ setupOnboarding() behavior (Woo-style) inside callback
297
- useLayoutEffect(() => {
298
- window.onboardingCallback = async function (authCode: string, sharedId: string) {
299
- // Woo sets this; keep safe
300
- try {
301
- ;(window as any).onbeforeunload = ""
302
- } catch {}
303
-
304
- // show blocking state (no new UI components, just disable + loader text)
305
- setOnboardingInProgress(true)
306
- setConnState("loading")
307
- setError(null)
308
-
309
- // post to backend (authCode/sharedId/env)
310
- const payload = {
311
- authCode,
312
- sharedId,
313
- env: env === "sandbox" ? "sandbox" : "live",
314
- }
315
-
316
- try {
317
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
318
- method: "POST",
319
- headers: { "content-type": "application/json" },
320
- body: JSON.stringify(payload),
321
- })
322
-
323
- if (!res.ok) {
324
- const txt = await res.text().catch(() => "")
325
- throw new Error(txt || `Onboarding exchange failed (${res.status})`)
326
- }
327
-
328
- // ✅ ONLY NOW close the mini-browser flow
329
- try {
330
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
331
- if (typeof close1 === "function") close1()
332
- } catch {}
333
- try {
334
- const close2 =
335
- window.PAYPAL?.apps?.Signup?.miniBrowser &&
336
- (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
337
- if (typeof close2 === "function") close2()
338
- } catch {}
339
-
340
- // clear cache so next run fetches new url
341
- try {
342
- localStorage.removeItem(CACHE_KEY)
343
- localStorage.removeItem(RELOAD_KEY)
344
- } catch {}
345
-
346
- // Woo does: window.location.href = window.location.href;
347
- window.location.href = window.location.href
348
- } catch (e: any) {
349
- console.error(e)
350
- setConnState("error")
351
- setError(e?.message || "Exchange failed while saving credentials.")
352
- setOnboardingInProgress(false)
353
- }
354
- }
355
-
356
- return () => {
357
- window.onboardingCallback = undefined
358
- }
359
- }, [env])
360
-
361
- // Measure PayPal button width (for OR centering under button)
362
- useLayoutEffect(() => {
363
- const el = ppBtnMeasureRef.current
364
- if (!el) return
365
-
366
- const update = () => {
367
- const w = Math.round(el.getBoundingClientRect().width || 0)
368
- if (w > 0) setPpBtnWidth(w)
369
- }
370
-
371
- update()
372
-
373
- let ro: ResizeObserver | null = null
374
- if (typeof ResizeObserver !== "undefined") {
375
- ro = new ResizeObserver(() => update())
376
- ro.observe(el)
377
- } else {
378
- window.addEventListener("resize", update)
379
- }
380
-
381
- return () => {
382
- if (ro) ro.disconnect()
383
- else window.removeEventListener("resize", update)
384
- }
385
- }, [connState, env, finalUrl])
386
-
387
- const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
388
- if (connState !== "ready" || !finalUrl || onboardingInProgress) {
389
- e.preventDefault()
390
- }
391
- }
392
-
393
- const handleSaveManual = async () => {
394
- if (!canSaveManual || onboardingInProgress) return
395
- setOnboardingInProgress(true)
396
- setConnState("loading")
397
- setError(null)
398
-
399
- try {
400
- const res = await fetch(SAVE_CREDENTIALS_ENDPOINT, {
401
- method: "POST",
402
- headers: { "content-type": "application/json" },
403
- body: JSON.stringify({
404
- clientId: clientId.trim(),
405
- clientSecret: secret.trim(),
406
- }),
407
- })
408
-
409
- if (!res.ok) {
410
- const txt = await res.text().catch(() => "")
411
- throw new Error(txt || `Save credentials failed (${res.status})`)
412
- }
413
-
414
- setConnState("connected")
415
- setStatusInfo({
416
- seller_client_id_masked: maskValue(clientId.trim()),
417
- seller_client_secret_masked: "••••••••",
418
- })
419
- setShowManual(false)
420
-
421
- try {
422
- localStorage.removeItem(CACHE_KEY)
423
- localStorage.removeItem(RELOAD_KEY)
424
- } catch {}
425
- } catch (e: any) {
426
- console.error(e)
427
- setConnState("error")
428
- setError(e?.message || "Failed to save credentials.")
429
- } finally {
430
- setOnboardingInProgress(false)
431
- }
432
- }
433
-
434
- const handleDisconnect = async () => {
435
- if (onboardingInProgress) return
436
- if (!window.confirm("Disconnect PayPal for this environment?")) return
437
-
438
- setOnboardingInProgress(true)
439
- setConnState("loading")
440
- setError(null)
441
- setFinalUrl("")
442
- setShowManual(false)
443
-
444
- try {
445
- const res = await fetch(DISCONNECT_ENDPOINT, {
446
- method: "POST",
447
- headers: { "content-type": "application/json" },
448
- body: JSON.stringify({ environment: env }),
449
- })
450
-
451
- if (!res.ok) {
452
- const t = await res.text().catch(() => "")
453
- throw new Error(t || `Disconnect failed (${res.status})`)
454
- }
455
-
456
- try {
457
- localStorage.removeItem(CACHE_KEY)
458
- localStorage.removeItem(RELOAD_KEY)
459
- } catch {}
460
-
461
- // Restart onboarding link generation for current env
462
- currentRunId.current = ++runIdRef.current
463
- const runId = currentRunId.current
464
- fetchFreshLink(runId)
465
- } catch (e: any) {
466
- console.error(e)
467
- setConnState("error")
468
- setError(e?.message || "Failed to disconnect.")
469
- } finally {
470
- setOnboardingInProgress(false)
471
- }
472
- }
473
-
474
- const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
475
- const next = e.target.value as "sandbox" | "live"
476
- setEnv(next)
477
- cachedUrl = null
478
-
479
- try {
480
- await fetch("/admin/paypal/environment", {
481
- method: "POST",
482
- headers: { "content-type": "application/json" },
483
- body: JSON.stringify({ environment: next }),
484
- })
485
- } catch {}
486
-
487
- try {
488
- localStorage.removeItem(CACHE_KEY)
489
- localStorage.removeItem(RELOAD_KEY)
490
- } catch {}
491
- }
492
-
493
- return (
494
- <div className="p-6">
495
- <div className="flex flex-col gap-6">
496
- <h1 className="text-xl font-semibold">PayPal Gateway By Easy Payment</h1>
497
-
498
- {/* Tabs header */}
499
- <PayPalTabs />
500
-
501
- {/* Main container */}
502
- <div className="rounded-md border border-ui-border-base p-4 shadow-sm">
503
- <div className="grid grid-cols-1 gap-y-6 md:grid-cols-[260px_1fr] md:items-start">
504
- {/* Environment */}
505
- <div className="text-sm font-medium pt-2">Environment</div>
506
- <div className="max-w-xl">
507
- <select
508
- value={env}
509
- onChange={handleEnvChange}
510
- disabled={onboardingInProgress}
511
- className="w-full rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm"
512
- >
513
- <option value="sandbox">Sandbox (Test Mode)</option>
514
- <option value="live">Live (Production)</option>
515
- </select>
516
- </div>
517
-
518
- {/* Connect */}
519
- <div className="text-sm font-medium pt-2">
520
- {env === "sandbox" ? "Connect to PayPal (Sandbox)" : "Connect to PayPal"}
521
- </div>
522
-
523
- <div className="max-w-xl">
524
- {connState === "connected" ? (
525
- <div>
526
- <div className="text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200">
527
- ✅ Successfully connected to PayPal!
528
- {/* Hidden PayPal button anchor to satisfy partner.js DOM expectations */}
529
- <a
530
- data-paypal-button="true"
531
- data-paypal-onboard-complete="onboardingCallback"
532
- href="#"
533
- style={{ display: "none" }}
534
- >
535
- PayPal
536
- </a>
537
- </div>
538
- <div className="mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle">
539
- <div className="font-medium text-ui-fg-base">
540
- Connected PayPal account
541
- </div>
542
- <div className="mt-1">
543
- Email:{" "}
544
- <span className="font-mono text-ui-fg-base">
545
- {statusInfo?.seller_email || "Unavailable"}
546
- </span>
547
- </div>
548
- </div>
549
- <div className="mt-3 flex items-center gap-2">
550
- <button
551
- type="button"
552
- onClick={handleDisconnect}
553
- disabled={onboardingInProgress}
554
- className="rounded-md border border-ui-border-base px-3 py-2 text-sm font-medium transition-colors hover:bg-ui-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ui-border-interactive disabled:opacity-50 disabled:cursor-not-allowed"
555
- >
556
- Disconnect
557
- </button>
558
- </div>
559
- </div>
560
- ) : (
561
- <>
562
- {/* Status Loader */}
563
- <div
564
- ref={initLoaderRef}
565
- id="init-loader"
566
- className={`status-msg mb-4 ${
567
- connState !== "loading" ? "hidden" : "block"
568
- }`}
569
- >
570
- <div className="loader inline-block align-middle mr-2"></div>
571
- <span id="loader-text" className="text-sm">
572
- {onboardingInProgress
573
- ? "Configuring connection to PayPal…"
574
- : "Checking connection..."}
575
- </span>
576
- </div>
577
-
578
- {/* PayPal Button */}
579
- <div className={`${connState === "ready" ? "block" : "hidden"}`}>
580
- {/* Row 1: button (left aligned) */}
581
- <a
582
- ref={(node) => {
583
- paypalButtonRef.current = node
584
- ppBtnMeasureRef.current = node
585
- }}
586
- id="paypal-button"
587
- data-paypal-button="true"
588
- href={finalUrl || "#"}
589
- data-paypal-onboard-complete="onboardingCallback"
590
- onClick={handleConnectClick}
591
- className="inline-flex items-center justify-center rounded-md bg-ui-button-neutral px-4 py-2 text-sm font-medium text-ui-fg-on-color no-underline shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ui-border-interactive disabled:opacity-60"
592
- style={{
593
- cursor: onboardingInProgress ? "not-allowed" : "pointer",
594
- opacity: onboardingInProgress ? 0.6 : 1,
595
- pointerEvents: onboardingInProgress ? "none" : "auto",
596
- }}
597
- >
598
- Connect to PayPal
599
- </a>
600
-
601
- {/* Row 2: OR centered under button width */}
602
- <div
603
- className="mt-2"
604
- style={{
605
- width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
606
- marginTop: "20px",
607
- marginBottom: "10px",
608
- }}
609
- >
610
- <div className="flex justify-center">
611
- <span className="text-[11px] text-ui-fg-muted leading-none">
612
- OR
613
- </span>
614
- </div>
615
- </div>
616
-
617
- {/* Row 3: manual link aligned to button LEFT */}
618
- <div className="mt-1">
619
- <button
620
- type="button"
621
- onClick={() => setShowManual(!showManual)}
622
- disabled={onboardingInProgress}
623
- className="text-sm text-ui-fg-interactive underline whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
624
- >
625
- Click here to insert credentials manually
626
- </button>
627
- </div>
628
- </div>
629
-
630
- {/* If not ready yet, show manual link still */}
631
- <div className={`${connState === "ready" ? "hidden" : "block"} mt-3`}>
632
- <button
633
- type="button"
634
- onClick={() => setShowManual(!showManual)}
635
- disabled={onboardingInProgress}
636
- className="text-sm text-ui-fg-interactive underline whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
637
- >
638
- Click here to insert credentials manually
639
- </button>
640
- </div>
641
-
642
- {/* Error Log */}
643
- <div
644
- ref={errorLogRef}
645
- id="error-log"
646
- className={`mt-4 text-left text-xs bg-red-50 text-red-600 p-3 border border-red-200 rounded ${
647
- connState === "error" && error ? "block" : "hidden"
648
- }`}
649
- >
650
- {error}
651
- </div>
652
- </>
653
- )}
654
- </div>
655
-
656
- {/* Manual credentials section */}
657
- {showManual && (
658
- <div className="md:col-span-2">
659
- <div className="ml-[260px] max-w-xl mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
660
- <div className="flex flex-col gap-1">
661
- <label className="text-sm font-medium">Client ID</label>
662
- <input
663
- type="text"
664
- value={clientId}
665
- onChange={(e) => setClientId(e.target.value)}
666
- disabled={onboardingInProgress}
667
- className="rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm disabled:opacity-50"
668
- placeholder={
669
- env === "sandbox" ? "Sandbox Client ID" : "Live Client ID"
670
- }
671
- />
672
- </div>
673
-
674
- <div className="flex flex-col gap-1">
675
- <label className="text-sm font-medium">Secret</label>
676
- <input
677
- type="password"
678
- value={secret}
679
- onChange={(e) => setSecret(e.target.value)}
680
- disabled={onboardingInProgress}
681
- className="rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm disabled:opacity-50"
682
- placeholder={env === "sandbox" ? "Sandbox Secret" : "Live Secret"}
683
- />
684
- </div>
685
-
686
- <div className="md:col-span-2 rounded-md border border-ui-border-base bg-ui-bg-subtle p-4 text-sm text-ui-fg-subtle">
687
- <p className="font-medium text-ui-fg-base">
688
- Get your Client ID and Secret in 3 steps:
689
- </p>
690
- <ol className="mt-2 list-decimal space-y-2 pl-5">
691
- <li>
692
- Open{" "}
693
- <a
694
- href="https://developer.paypal.com/dashboard/"
695
- target="_blank"
696
- rel="noreferrer"
697
- className="text-ui-fg-interactive underline"
698
- >
699
- Log in to Dashboard
700
- </a>{" "}
701
- and sign in or create an account.
702
- </li>
703
- <li>Select <span className="font-medium text-ui-fg-base">Apps & Credentials</span>, then choose <span className="font-medium text-ui-fg-base">Create App</span> if you need a new project.</li>
704
- <li>Copy your app's <span className="font-medium text-ui-fg-base">Client ID</span> and <span className="font-medium text-ui-fg-base">Secret</span>, paste them above, then click <span className="font-medium text-ui-fg-base">Save credentials</span>.</li>
705
- </ol>
706
- </div>
707
-
708
- <div className="md:col-span-2 flex items-center gap-2 mt-2">
709
- <button
710
- type="button"
711
- className="rounded-md border border-ui-border-base px-3 py-2 text-sm font-medium hover:bg-ui-bg-subtle disabled:opacity-50 disabled:cursor-not-allowed"
712
- onClick={() => setShowManual(false)}
713
- disabled={onboardingInProgress}
714
- >
715
- Cancel
716
- </button>
717
-
718
- <button
719
- type="button"
720
- className="rounded-md border border-ui-border-base px-3 py-2 text-sm font-medium bg-ui-bg-base hover:bg-ui-bg-subtle disabled:opacity-50 disabled:cursor-not-allowed"
721
- disabled={!canSaveManual || onboardingInProgress}
722
- onClick={handleSaveManual}
723
- >
724
- Save credentials
725
- </button>
726
- </div>
727
- </div>
728
- </div>
729
- )}
730
- </div>
731
- </div>
732
- </div>
733
-
734
- {/* Loader styles */}
735
- <style>{`
736
- .loader {
737
- border: 3px solid #f3f3f3;
738
- border-top: 3px solid #0070ba;
739
- border-radius: 50%;
740
- width: 18px;
741
- height: 18px;
742
- animation: spin 1s linear infinite;
743
- display: inline-block;
744
- vertical-align: middle;
745
- margin-right: 8px;
746
- }
747
- @keyframes spin {
748
- 0% { transform: rotate(0deg); }
749
- 100% { transform: rotate(360deg); }
750
- }
751
- `}</style>
752
- </div>
753
- )
754
- }
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react"
9
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
10
+ import PayPalTabs from "../_components/Tabs"
11
+
12
+ export const config = defineRouteConfig({
13
+ label: "PayPal Connection",
14
+ hide: true,
15
+ })
16
+
17
+ /* ------------------------------------------------------------------ */
18
+ /* HIGH PRIORITY EXECUTION - RUNS BEFORE COMPONENT MOUNTS */
19
+ /* ------------------------------------------------------------------ */
20
+
21
+ // 1. Inject Preload Link IMMEDIATELY
22
+ if (typeof window !== "undefined") {
23
+ const preloadHref =
24
+ "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js"
25
+
26
+ const existingPreload = document.head.querySelector(
27
+ `link[rel="preload"][href="${preloadHref}"]`
28
+ )
29
+ if (!existingPreload) {
30
+ const preloadLink = document.createElement("link")
31
+ preloadLink.rel = "preload"
32
+ preloadLink.href = preloadHref
33
+ preloadLink.as = "script"
34
+ document.head.appendChild(preloadLink)
35
+ }
36
+
37
+ // 2. Inject PayPal Script IMMEDIATELY (before React renders)
38
+ const existingScript = document.getElementById(
39
+ "paypal-partner-js"
40
+ ) as HTMLScriptElement | null
41
+ if (!existingScript) {
42
+ const ppScript = document.createElement("script")
43
+ ppScript.id = "paypal-partner-js"
44
+ ppScript.src = preloadHref
45
+ ppScript.async = true
46
+ document.head.appendChild(ppScript)
47
+ }
48
+ }
49
+
50
+ declare global {
51
+ interface Window {
52
+ PAYPAL?: {
53
+ apps?: {
54
+ Signup?: {
55
+ miniBrowser?: { init: () => void }
56
+ // some partner.js builds expose MiniBrowser (capital M)
57
+ MiniBrowser?: { closeFlow?: () => void }
58
+ }
59
+ }
60
+ }
61
+ onboardingCallback?: (authCode: string, sharedId: string) => void
62
+ }
63
+ }
64
+
65
+ // Partner.js is now loaded lazily only when needed (removed global injection)
66
+
67
+ const SERVICE_URL = "/admin/paypal/onboarding-link"
68
+ const CACHE_KEY = "pp_onboard_cache"
69
+ const RELOAD_KEY = "pp_onboard_reloaded_once"
70
+ const CACHE_EXPIRY = 10 * 60 * 1000 // 10 minutes
71
+
72
+ // ✅ backend endpoint to exchange authCode/sharedId (medusa)
73
+ const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
74
+ const STATUS_ENDPOINT = "/admin/paypal/status"
75
+ const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials"
76
+ const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect"
77
+
78
+ // 3. Immediate Cache Check (runs before React component)
79
+ let cachedUrl: string | null = null
80
+ if (typeof window !== "undefined") {
81
+ try {
82
+ const cached = localStorage.getItem(CACHE_KEY)
83
+ if (cached) {
84
+ const data = JSON.parse(cached)
85
+ if (new Date().getTime() - data.ts < CACHE_EXPIRY) {
86
+ cachedUrl = data.url
87
+ }
88
+ }
89
+ } catch (e) {
90
+ console.error("Cache read error:", e)
91
+ }
92
+ }
93
+
94
+ /* ------------------------------------------------------------------ */
95
+ /* REACT COMPONENT */
96
+ /* ------------------------------------------------------------------ */
97
+
98
+ export default function PayPalConnectionPage() {
99
+ const [env, setEnv] = useState<"sandbox" | "live">("live")
100
+
101
+ // Sync initial environment from backend
102
+ useEffect(() => {
103
+ fetch("/admin/paypal/environment", { method: "GET" })
104
+ .then((r) => r.json())
105
+ .then((d) => {
106
+ const v = d?.environment === "sandbox" ? "sandbox" : "live"
107
+ setEnv(v)
108
+ })
109
+ .catch(() => {})
110
+ }, [])
111
+ const [connState, setConnState] = useState<
112
+ "loading" | "ready" | "connected" | "error"
113
+ >("loading")
114
+ const [error, setError] = useState<string | null>(null)
115
+ const [finalUrl, setFinalUrl] = useState<string>("")
116
+ const [showManual, setShowManual] = useState(false)
117
+ const [clientId, setClientId] = useState("")
118
+ const [secret, setSecret] = useState("")
119
+ const [statusInfo, setStatusInfo] = useState<{
120
+ seller_client_id_masked?: string | null
121
+ seller_client_secret_masked?: string | null
122
+ seller_email?: string | null
123
+ } | null>(null)
124
+
125
+ // ✅ onboarding in progress (for disabling UI)
126
+ const [onboardingInProgress, setOnboardingInProgress] = useState(false)
127
+
128
+ const initLoaderRef = useRef<HTMLDivElement>(null)
129
+ const paypalButtonRef = useRef<HTMLAnchorElement>(null)
130
+ const errorLogRef = useRef<HTMLDivElement>(null)
131
+ const runIdRef = useRef(0)
132
+ const currentRunId = useRef(0)
133
+
134
+ // Measure PayPal button width for OR centering (Woo-style)
135
+ const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
136
+ const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
137
+
138
+ const canSaveManual = useMemo(() => {
139
+ return clientId.trim().length > 0 && secret.trim().length > 0
140
+ }, [clientId, secret])
141
+
142
+ const maskValue = useCallback((value: string, visibleChars = 4) => {
143
+ if (!value) return ""
144
+ if (value.length <= visibleChars) {
145
+ return "•".repeat(value.length)
146
+ }
147
+ return `${"•".repeat(Math.max(0, value.length - visibleChars))}${value.slice(
148
+ -visibleChars
149
+ )}`
150
+ }, [])
151
+
152
+ // Exact copy of fetchFreshLink from original JS (+ reload-once logic ONLY)
153
+ const fetchFreshLink = useCallback(
154
+ (runId: number) => {
155
+ if (initLoaderRef.current) {
156
+ const loaderText = initLoaderRef.current.querySelector("#loader-text")
157
+ if (loaderText)
158
+ loaderText.textContent = "Generating onboarding session..."
159
+ }
160
+
161
+ fetch(SERVICE_URL, {
162
+ method: "POST",
163
+ headers: { "content-type": "application/json" },
164
+ body: JSON.stringify({
165
+ products: ["PPCP"],
166
+ }),
167
+ })
168
+ .then((r) => r.json())
169
+ .then((data) => {
170
+ if (runId !== currentRunId.current) return
171
+
172
+ const href = data?.onboarding_url
173
+ if (!href) {
174
+ showError("Onboarding URL not returned.")
175
+ return
176
+ }
177
+
178
+ const finalUrl =
179
+ href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
180
+
181
+ localStorage.setItem(
182
+ CACHE_KEY,
183
+ JSON.stringify({
184
+ url: finalUrl,
185
+ ts: Date.now(),
186
+ })
187
+ )
188
+
189
+ if (!localStorage.getItem(RELOAD_KEY)) {
190
+ localStorage.setItem(RELOAD_KEY, "1")
191
+ window.location.reload()
192
+ return
193
+ }
194
+
195
+ activatePayPal(finalUrl, runId)
196
+ })
197
+ .catch(() => {
198
+ if (runId !== currentRunId.current) return
199
+ showError("Unable to connect to service.")
200
+ })
201
+ },
202
+ [env]
203
+ )
204
+
205
+ // Exact copy of showUI from original JS (safe: only init when button exists)
206
+ const showUI = useCallback(() => {
207
+ const btn = document.querySelector('[data-paypal-button="true"]')
208
+ if (btn && window.PAYPAL?.apps?.Signup?.miniBrowser?.init) {
209
+ window.PAYPAL.apps.Signup.miniBrowser.init()
210
+ }
211
+ setConnState("ready")
212
+ }, [])
213
+
214
+ // Exact copy of showError from original JS
215
+ const showError = useCallback((msg: string) => {
216
+ setConnState("error")
217
+ setError(msg)
218
+ }, [])
219
+
220
+ // activatePayPal (keeps PayPal partner.js global injection; waits for PAYPAL then inits)
221
+ const activatePayPal = useCallback(
222
+ (url: string, runId: number) => {
223
+ if (paypalButtonRef.current) {
224
+ paypalButtonRef.current.href = url
225
+ }
226
+ setFinalUrl(url)
227
+
228
+ const tryInit = () => {
229
+ if (runId !== currentRunId.current) return
230
+ if (window.PAYPAL?.apps?.Signup) {
231
+ showUI()
232
+ return
233
+ }
234
+ // wait briefly for partner.js to finish loading
235
+ setTimeout(tryInit, 50)
236
+ }
237
+
238
+ tryInit()
239
+ },
240
+ [showUI]
241
+ )
242
+
243
+ // Initialize - runs on mount and env change
244
+ useEffect(() => {
245
+ currentRunId.current = ++runIdRef.current
246
+ const runId = currentRunId.current
247
+
248
+ let cancelled = false
249
+
250
+ const run = async () => {
251
+ setConnState("loading")
252
+ setError(null)
253
+ setFinalUrl("")
254
+
255
+ // 1) If already connected in DB, don't ask to onboard again
256
+ try {
257
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
258
+ method: "GET",
259
+ })
260
+ const st = await r.json().catch(() => ({}))
261
+
262
+ if (cancelled || runId !== currentRunId.current) return
263
+
264
+ setStatusInfo(st)
265
+
266
+ const isConnected =
267
+ st?.status === "connected" && st?.seller_client_id_present === true
268
+
269
+ if (isConnected) {
270
+ setConnState("connected")
271
+ setShowManual(false)
272
+ return
273
+ }
274
+ } catch (e) {
275
+ // ignore status errors; proceed with onboarding
276
+ console.error(e)
277
+ }
278
+
279
+ // 2) Not connected -> continue onboarding flow
280
+ if (cachedUrl) {
281
+ console.log("Using prioritized cache...")
282
+ activatePayPal(cachedUrl, runId)
283
+ } else {
284
+ fetchFreshLink(runId)
285
+ }
286
+ }
287
+
288
+ run()
289
+
290
+ return () => {
291
+ cancelled = true
292
+ currentRunId.current = 0
293
+ }
294
+ }, [env, fetchFreshLink, activatePayPal])
295
+
296
+ // ✅ setupOnboarding() behavior (Woo-style) inside callback
297
+ useLayoutEffect(() => {
298
+ window.onboardingCallback = async function (authCode: string, sharedId: string) {
299
+ // Woo sets this; keep safe
300
+ try {
301
+ ;(window as any).onbeforeunload = ""
302
+ } catch {}
303
+
304
+ // show blocking state (no new UI components, just disable + loader text)
305
+ setOnboardingInProgress(true)
306
+ setConnState("loading")
307
+ setError(null)
308
+
309
+ // post to backend (authCode/sharedId/env)
310
+ const payload = {
311
+ authCode,
312
+ sharedId,
313
+ env: env === "sandbox" ? "sandbox" : "live",
314
+ }
315
+
316
+ try {
317
+ const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
318
+ method: "POST",
319
+ headers: { "content-type": "application/json" },
320
+ body: JSON.stringify(payload),
321
+ })
322
+
323
+ if (!res.ok) {
324
+ const txt = await res.text().catch(() => "")
325
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`)
326
+ }
327
+
328
+ // ✅ ONLY NOW close the mini-browser flow
329
+ try {
330
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
331
+ if (typeof close1 === "function") close1()
332
+ } catch {}
333
+ try {
334
+ const close2 =
335
+ window.PAYPAL?.apps?.Signup?.miniBrowser &&
336
+ (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
337
+ if (typeof close2 === "function") close2()
338
+ } catch {}
339
+
340
+ // clear cache so next run fetches new url
341
+ try {
342
+ localStorage.removeItem(CACHE_KEY)
343
+ localStorage.removeItem(RELOAD_KEY)
344
+ } catch {}
345
+
346
+ // Woo does: window.location.href = window.location.href;
347
+ window.location.href = window.location.href
348
+ } catch (e: any) {
349
+ console.error(e)
350
+ setConnState("error")
351
+ setError(e?.message || "Exchange failed while saving credentials.")
352
+ setOnboardingInProgress(false)
353
+ }
354
+ }
355
+
356
+ return () => {
357
+ window.onboardingCallback = undefined
358
+ }
359
+ }, [env])
360
+
361
+ // Measure PayPal button width (for OR centering under button)
362
+ useLayoutEffect(() => {
363
+ const el = ppBtnMeasureRef.current
364
+ if (!el) return
365
+
366
+ const update = () => {
367
+ const w = Math.round(el.getBoundingClientRect().width || 0)
368
+ if (w > 0) setPpBtnWidth(w)
369
+ }
370
+
371
+ update()
372
+
373
+ let ro: ResizeObserver | null = null
374
+ if (typeof ResizeObserver !== "undefined") {
375
+ ro = new ResizeObserver(() => update())
376
+ ro.observe(el)
377
+ } else {
378
+ window.addEventListener("resize", update)
379
+ }
380
+
381
+ return () => {
382
+ if (ro) ro.disconnect()
383
+ else window.removeEventListener("resize", update)
384
+ }
385
+ }, [connState, env, finalUrl])
386
+
387
+ const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
388
+ if (connState !== "ready" || !finalUrl || onboardingInProgress) {
389
+ e.preventDefault()
390
+ }
391
+ }
392
+
393
+ const handleSaveManual = async () => {
394
+ if (!canSaveManual || onboardingInProgress) return
395
+ setOnboardingInProgress(true)
396
+ setConnState("loading")
397
+ setError(null)
398
+
399
+ try {
400
+ const res = await fetch(SAVE_CREDENTIALS_ENDPOINT, {
401
+ method: "POST",
402
+ headers: { "content-type": "application/json" },
403
+ body: JSON.stringify({
404
+ clientId: clientId.trim(),
405
+ clientSecret: secret.trim(),
406
+ }),
407
+ })
408
+
409
+ if (!res.ok) {
410
+ const txt = await res.text().catch(() => "")
411
+ throw new Error(txt || `Save credentials failed (${res.status})`)
412
+ }
413
+
414
+ setConnState("connected")
415
+ setStatusInfo({
416
+ seller_client_id_masked: maskValue(clientId.trim()),
417
+ seller_client_secret_masked: "••••••••",
418
+ })
419
+ setShowManual(false)
420
+
421
+ try {
422
+ localStorage.removeItem(CACHE_KEY)
423
+ localStorage.removeItem(RELOAD_KEY)
424
+ } catch {}
425
+ } catch (e: any) {
426
+ console.error(e)
427
+ setConnState("error")
428
+ setError(e?.message || "Failed to save credentials.")
429
+ } finally {
430
+ setOnboardingInProgress(false)
431
+ }
432
+ }
433
+
434
+ const handleDisconnect = async () => {
435
+ if (onboardingInProgress) return
436
+ if (!window.confirm("Disconnect PayPal for this environment?")) return
437
+
438
+ setOnboardingInProgress(true)
439
+ setConnState("loading")
440
+ setError(null)
441
+ setFinalUrl("")
442
+ setShowManual(false)
443
+
444
+ try {
445
+ const res = await fetch(DISCONNECT_ENDPOINT, {
446
+ method: "POST",
447
+ headers: { "content-type": "application/json" },
448
+ body: JSON.stringify({ environment: env }),
449
+ })
450
+
451
+ if (!res.ok) {
452
+ const t = await res.text().catch(() => "")
453
+ throw new Error(t || `Disconnect failed (${res.status})`)
454
+ }
455
+
456
+ try {
457
+ localStorage.removeItem(CACHE_KEY)
458
+ localStorage.removeItem(RELOAD_KEY)
459
+ } catch {}
460
+
461
+ // Restart onboarding link generation for current env
462
+ currentRunId.current = ++runIdRef.current
463
+ const runId = currentRunId.current
464
+ fetchFreshLink(runId)
465
+ } catch (e: any) {
466
+ console.error(e)
467
+ setConnState("error")
468
+ setError(e?.message || "Failed to disconnect.")
469
+ } finally {
470
+ setOnboardingInProgress(false)
471
+ }
472
+ }
473
+
474
+ const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
475
+ const next = e.target.value as "sandbox" | "live"
476
+ setEnv(next)
477
+ cachedUrl = null
478
+
479
+ try {
480
+ await fetch("/admin/paypal/environment", {
481
+ method: "POST",
482
+ headers: { "content-type": "application/json" },
483
+ body: JSON.stringify({ environment: next }),
484
+ })
485
+ } catch {}
486
+
487
+ try {
488
+ localStorage.removeItem(CACHE_KEY)
489
+ localStorage.removeItem(RELOAD_KEY)
490
+ } catch {}
491
+ }
492
+
493
+ return (
494
+ <div className="p-6">
495
+ <div className="flex flex-col gap-6">
496
+ <h1 className="text-xl font-semibold">PayPal Gateway By Easy Payment</h1>
497
+
498
+ {/* Tabs header */}
499
+ <PayPalTabs />
500
+
501
+ {/* Main container */}
502
+ <div className="rounded-md border border-ui-border-base p-4 shadow-sm">
503
+ <div className="grid grid-cols-1 gap-y-6 md:grid-cols-[260px_1fr] md:items-start">
504
+ {/* Environment */}
505
+ <div className="text-sm font-medium pt-2">Environment</div>
506
+ <div className="max-w-xl">
507
+ <select
508
+ value={env}
509
+ onChange={handleEnvChange}
510
+ disabled={onboardingInProgress}
511
+ className="w-full rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm"
512
+ >
513
+ <option value="sandbox">Sandbox (Test Mode)</option>
514
+ <option value="live">Live (Production)</option>
515
+ </select>
516
+ </div>
517
+
518
+ {/* Connect */}
519
+ <div className="text-sm font-medium pt-2">
520
+ {env === "sandbox" ? "Connect to PayPal (Sandbox)" : "Connect to PayPal"}
521
+ </div>
522
+
523
+ <div className="max-w-xl">
524
+ {connState === "connected" ? (
525
+ <div>
526
+ <div className="text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200">
527
+ ✅ Successfully connected to PayPal!
528
+ {/* Hidden PayPal button anchor to satisfy partner.js DOM expectations */}
529
+ <a
530
+ data-paypal-button="true"
531
+ data-paypal-onboard-complete="onboardingCallback"
532
+ href="#"
533
+ style={{ display: "none" }}
534
+ >
535
+ PayPal
536
+ </a>
537
+ </div>
538
+ <div className="mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle">
539
+ <div className="font-medium text-ui-fg-base">
540
+ Connected PayPal account
541
+ </div>
542
+ <div className="mt-1">
543
+ Email:{" "}
544
+ <span className="font-mono text-ui-fg-base">
545
+ {statusInfo?.seller_email || "Unavailable"}
546
+ </span>
547
+ </div>
548
+ </div>
549
+ <div className="mt-3 flex items-center gap-2">
550
+ <button
551
+ type="button"
552
+ onClick={handleDisconnect}
553
+ disabled={onboardingInProgress}
554
+ className="rounded-md border border-ui-border-base px-3 py-2 text-sm font-medium transition-colors hover:bg-ui-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ui-border-interactive disabled:opacity-50 disabled:cursor-not-allowed"
555
+ >
556
+ Disconnect
557
+ </button>
558
+ </div>
559
+ </div>
560
+ ) : (
561
+ <>
562
+ {/* Status Loader */}
563
+ <div
564
+ ref={initLoaderRef}
565
+ id="init-loader"
566
+ className={`status-msg mb-4 ${
567
+ connState !== "loading" ? "hidden" : "block"
568
+ }`}
569
+ >
570
+ <div className="loader inline-block align-middle mr-2"></div>
571
+ <span id="loader-text" className="text-sm">
572
+ {onboardingInProgress
573
+ ? "Configuring connection to PayPal…"
574
+ : "Checking connection..."}
575
+ </span>
576
+ </div>
577
+
578
+ {/* PayPal Button */}
579
+ <div className={`${connState === "ready" ? "block" : "hidden"}`}>
580
+ {/* Row 1: button (left aligned) */}
581
+ <a
582
+ ref={(node) => {
583
+ paypalButtonRef.current = node
584
+ ppBtnMeasureRef.current = node
585
+ }}
586
+ id="paypal-button"
587
+ data-paypal-button="true"
588
+ href={finalUrl || "#"}
589
+ data-paypal-onboard-complete="onboardingCallback"
590
+ onClick={handleConnectClick}
591
+ className="inline-flex items-center justify-center rounded-md bg-ui-button-neutral px-4 py-2 text-sm font-medium text-ui-fg-on-color no-underline shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ui-border-interactive disabled:opacity-60"
592
+ style={{
593
+ cursor: onboardingInProgress ? "not-allowed" : "pointer",
594
+ opacity: onboardingInProgress ? 0.6 : 1,
595
+ pointerEvents: onboardingInProgress ? "none" : "auto",
596
+ }}
597
+ >
598
+ Connect to PayPal
599
+ </a>
600
+
601
+ {/* Row 2: OR centered under button width */}
602
+ <div
603
+ className="mt-2"
604
+ style={{
605
+ width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
606
+ marginTop: "20px",
607
+ marginBottom: "10px",
608
+ }}
609
+ >
610
+ <div className="flex justify-center">
611
+ <span className="text-[11px] text-ui-fg-muted leading-none">
612
+ OR
613
+ </span>
614
+ </div>
615
+ </div>
616
+
617
+ {/* Row 3: manual link aligned to button LEFT */}
618
+ <div className="mt-1">
619
+ <button
620
+ type="button"
621
+ onClick={() => setShowManual(!showManual)}
622
+ disabled={onboardingInProgress}
623
+ className="text-sm text-ui-fg-interactive underline whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
624
+ >
625
+ Click here to insert credentials manually
626
+ </button>
627
+ </div>
628
+ </div>
629
+
630
+ {/* If not ready yet, show manual link still */}
631
+ <div className={`${connState === "ready" ? "hidden" : "block"} mt-3`}>
632
+ <button
633
+ type="button"
634
+ onClick={() => setShowManual(!showManual)}
635
+ disabled={onboardingInProgress}
636
+ className="text-sm text-ui-fg-interactive underline whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
637
+ >
638
+ Click here to insert credentials manually
639
+ </button>
640
+ </div>
641
+
642
+ {/* Error Log */}
643
+ <div
644
+ ref={errorLogRef}
645
+ id="error-log"
646
+ className={`mt-4 text-left text-xs bg-red-50 text-red-600 p-3 border border-red-200 rounded ${
647
+ connState === "error" && error ? "block" : "hidden"
648
+ }`}
649
+ >
650
+ {error}
651
+ </div>
652
+ </>
653
+ )}
654
+ </div>
655
+
656
+ {/* Manual credentials section */}
657
+ {showManual && (
658
+ <div className="md:col-span-2">
659
+ <div className="ml-[260px] max-w-xl mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
660
+ <div className="flex flex-col gap-1">
661
+ <label className="text-sm font-medium">Client ID</label>
662
+ <input
663
+ type="text"
664
+ value={clientId}
665
+ onChange={(e) => setClientId(e.target.value)}
666
+ disabled={onboardingInProgress}
667
+ className="rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm disabled:opacity-50"
668
+ placeholder={
669
+ env === "sandbox" ? "Sandbox Client ID" : "Live Client ID"
670
+ }
671
+ />
672
+ </div>
673
+
674
+ <div className="flex flex-col gap-1">
675
+ <label className="text-sm font-medium">Secret</label>
676
+ <input
677
+ type="password"
678
+ value={secret}
679
+ onChange={(e) => setSecret(e.target.value)}
680
+ disabled={onboardingInProgress}
681
+ className="rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm disabled:opacity-50"
682
+ placeholder={env === "sandbox" ? "Sandbox Secret" : "Live Secret"}
683
+ />
684
+ </div>
685
+
686
+ <div className="md:col-span-2 rounded-md border border-ui-border-base bg-ui-bg-subtle p-4 text-sm text-ui-fg-subtle">
687
+ <p className="font-medium text-ui-fg-base">
688
+ Get your Client ID and Secret in 3 steps:
689
+ </p>
690
+ <ol className="mt-2 list-decimal space-y-2 pl-5">
691
+ <li>
692
+ Open{" "}
693
+ <a
694
+ href="https://developer.paypal.com/dashboard/"
695
+ target="_blank"
696
+ rel="noreferrer"
697
+ className="text-ui-fg-interactive underline"
698
+ >
699
+ Log in to Dashboard
700
+ </a>{" "}
701
+ and sign in or create an account.
702
+ </li>
703
+ <li>Select <span className="font-medium text-ui-fg-base">Apps & Credentials</span>, then choose <span className="font-medium text-ui-fg-base">Create App</span> if you need a new project.</li>
704
+ <li>Copy your app's <span className="font-medium text-ui-fg-base">Client ID</span> and <span className="font-medium text-ui-fg-base">Secret</span>, paste them above, then click <span className="font-medium text-ui-fg-base">Save credentials</span>.</li>
705
+ </ol>
706
+ </div>
707
+
708
+ <div className="md:col-span-2 flex items-center gap-2 mt-2">
709
+ <button
710
+ type="button"
711
+ className="rounded-md border border-ui-border-base px-3 py-2 text-sm font-medium hover:bg-ui-bg-subtle disabled:opacity-50 disabled:cursor-not-allowed"
712
+ onClick={() => setShowManual(false)}
713
+ disabled={onboardingInProgress}
714
+ >
715
+ Cancel
716
+ </button>
717
+
718
+ <button
719
+ type="button"
720
+ className="rounded-md border border-ui-border-base px-3 py-2 text-sm font-medium bg-ui-bg-base hover:bg-ui-bg-subtle disabled:opacity-50 disabled:cursor-not-allowed"
721
+ disabled={!canSaveManual || onboardingInProgress}
722
+ onClick={handleSaveManual}
723
+ >
724
+ Save credentials
725
+ </button>
726
+ </div>
727
+ </div>
728
+ </div>
729
+ )}
730
+ </div>
731
+ </div>
732
+ </div>
733
+
734
+ {/* Loader styles */}
735
+ <style>{`
736
+ .loader {
737
+ border: 3px solid #f3f3f3;
738
+ border-top: 3px solid #0070ba;
739
+ border-radius: 50%;
740
+ width: 18px;
741
+ height: 18px;
742
+ animation: spin 1s linear infinite;
743
+ display: inline-block;
744
+ vertical-align: middle;
745
+ margin-right: 8px;
746
+ }
747
+ @keyframes spin {
748
+ 0% { transform: rotate(0deg); }
749
+ 100% { transform: rotate(360deg); }
750
+ }
751
+ `}</style>
752
+ </div>
753
+ )
754
+ }