@easypayment/medusa-paypal 0.3.0 → 0.3.2

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.
@@ -1,599 +1,510 @@
1
- import React, { useEffect, useMemo, useRef, useState } from "react"
2
- import PayPalTabs from "../_components/Tabs"
3
-
4
- type AdminFetchOptions = {
5
- method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
6
- body?: Record<string, unknown>
7
- query?: Record<string, string>
8
- }
9
-
10
- async function adminFetch<T = unknown>(path: string, opts: AdminFetchOptions = {}): Promise<T> {
11
- const { method = "GET", body, query } = opts
12
- let url = path
13
- if (query && Object.keys(query).length > 0) {
14
- const params = new URLSearchParams(query)
15
- url = `${path}?${params.toString()}`
16
- }
17
- const headers: Record<string, string> = { Accept: "application/json" }
18
- if (body !== undefined) headers["Content-Type"] = "application/json"
19
- if (typeof window !== "undefined") {
20
- const token = (window as any).__medusa__?.token
21
- if (token) headers["Authorization"] = `Bearer ${token}`
22
- }
23
- const res = await fetch(url, {
24
- method, headers, credentials: "include",
25
- body: body !== undefined ? JSON.stringify(body) : undefined,
26
- })
27
- const text = await res.text().catch(() => "")
28
- if (!res.ok) {
29
- if (res.status === 401) throw new Error("Unauthorized (401) - session may have expired. Please reload and log in again.")
30
- if (res.status === 403) throw new Error("Forbidden (403) - you do not have permission to perform this action.")
31
- throw new Error(text || `Request failed with status ${res.status}`)
32
- }
33
- if (!text) return {} as T
34
- try { return JSON.parse(text) as T } catch { return {} as T }
35
- }
36
-
37
- type ButtonColor = "gold" | "blue" | "silver" | "black" | "white"
38
- type ButtonShape = "rect" | "pill"
39
- type ButtonWidth = "small" | "medium" | "large" | "responsive"
40
- type ButtonLabel = "paypal" | "checkout" | "buynow" | "pay"
41
-
42
- type DisplayLocation =
43
- | "product"
44
- | "cart"
45
- | "checkout"
46
- | "express"
47
- | "mini_cart"
48
-
49
- type DisabledButton =
50
- | "paypal"
51
- | "paylater"
52
- | "card"
53
- | "venmo"
54
- | "applepay"
55
- | "googlepay"
56
-
57
- type PayPalSettingsForm = {
58
- enabled: boolean
59
- title: string
60
- description: string
61
- displayOn: DisplayLocation[]
62
- disableButtons: DisabledButton[]
63
- buttonColor: ButtonColor
64
- buttonShape: ButtonShape
65
- buttonWidth: ButtonWidth
66
- buttonHeight: number
67
- buttonLabel: ButtonLabel
68
- }
69
-
70
- const DISPLAY_LOCATION_OPTIONS: { value: DisplayLocation; label: string; disabled?: boolean; hint?: string }[] = [
71
- { value: "product", label: "Product Page" },
72
- { value: "cart", label: "Cart Page" },
73
- { value: "express", label: "Express Checkout" },
74
- { value: "checkout", label: "Checkout Page", disabled: true, hint: "Locked by PayPal eligibility / checkout config" },
75
- { value: "mini_cart", label: "Mini Cart (Side Cart)" },
76
- ]
77
-
78
- const DISABLE_BUTTON_OPTIONS: { value: DisabledButton; label: string }[] = [
79
- { value: "paypal", label: "PayPal" },
80
- { value: "paylater", label: "Pay Later" },
81
- { value: "card", label: "Debit / Credit Card" },
82
- { value: "venmo", label: "Venmo" },
83
- { value: "applepay", label: "Apple Pay" },
84
- { value: "googlepay", label: "Google Pay" },
85
- ]
86
-
87
- const HIDDEN_DISABLE_BUTTONS = new Set<DisabledButton>(["applepay", "googlepay", "paylater"])
88
-
89
- const VISIBLE_DISABLE_BUTTON_OPTIONS = DISABLE_BUTTON_OPTIONS.filter(
90
- (option) => !HIDDEN_DISABLE_BUTTONS.has(option.value)
91
- )
92
-
93
- function filterHiddenDisableButtons(list: DisabledButton[] = []) {
94
- return list.filter((value) => !HIDDEN_DISABLE_BUTTONS.has(value))
95
- }
96
-
97
- const COLOR_OPTIONS: { value: ButtonColor; label: string }[] = [
98
- { value: "gold", label: "Gold (Recommended)" },
99
- { value: "blue", label: "Blue" },
100
- { value: "silver", label: "Silver" },
101
- { value: "black", label: "Black" },
102
- { value: "white", label: "White" },
103
- ]
104
-
105
- const SHAPE_OPTIONS: { value: ButtonShape; label: string }[] = [
106
- { value: "rect", label: "Rect (Recommended)" },
107
- { value: "pill", label: "Pill" },
108
- ]
109
-
110
- const WIDTH_OPTIONS: { value: ButtonWidth; label: string }[] = [
111
- { value: "small", label: "Small" },
112
- { value: "medium", label: "Medium" },
113
- { value: "large", label: "Large" },
114
- { value: "responsive", label: "Responsive" },
115
- ]
116
-
117
- const HEIGHT_OPTIONS: number[] = [32, 36, 40, 44, 48, 52, 56]
118
-
119
- const LABEL_OPTIONS: { value: ButtonLabel; label: string }[] = [
120
- { value: "paypal", label: "PayPal (Recommended)" },
121
- { value: "checkout", label: "Checkout" },
122
- { value: "buynow", label: "Buy Now" },
123
- { value: "pay", label: "Pay" },
124
- ]
125
-
126
- function cx(...parts: Array<string | false | undefined | null>) {
127
- return parts.filter(Boolean).join(" ")
128
- }
129
-
130
- function Pill({
131
- children,
132
- onRemove,
133
- disabled,
134
- }: {
135
- children: React.ReactNode
136
- onRemove?: () => void
137
- disabled?: boolean
138
- }) {
139
- return (
140
- <span
141
- className={cx(
142
- "inline-flex items-center gap-1 rounded-md border px-2 py-1 text-sm",
143
- disabled ? "opacity-60" : "opacity-100"
144
- )}
145
- >
146
- {children}
147
- {onRemove ? (
148
- <button
149
- type="button"
150
- onClick={onRemove}
151
- className="ml-1 rounded px-1 text-ui-fg-subtle hover:text-ui-fg-base"
152
- aria-label="Remove"
153
- >
154
- ×
155
- </button>
156
- ) : null}
157
- </span>
158
- )
159
- }
160
-
161
- function SectionCard({
162
- title,
163
- description,
164
- children,
165
- right,
166
- }: {
167
- title: string
168
- description?: string
169
- children: React.ReactNode
170
- right?: React.ReactNode
171
- }) {
172
- return (
173
- <div className="rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm">
174
- <div className="flex items-start justify-between gap-4 border-b border-ui-border-base p-4">
175
- <div>
176
- <div className="text-base font-semibold text-ui-fg-base">{title}</div>
177
- {description ? <div className="mt-1 text-sm text-ui-fg-subtle">{description}</div> : null}
178
- </div>
179
- {right}
180
- </div>
181
- <div className="p-4">{children}</div>
182
- </div>
183
- )
184
- }
185
-
186
- function FieldRow({
187
- label,
188
- hint,
189
- children,
190
- }: {
191
- label: string
192
- hint?: React.ReactNode
193
- children: React.ReactNode
194
- }) {
195
- return (
196
- <div className="grid grid-cols-12 items-start gap-4 py-3">
197
- <div className="col-span-12 md:col-span-4">
198
- <div className="text-sm font-medium text-ui-fg-base">{label}</div>
199
- {hint ? <div className="mt-1 text-xs text-ui-fg-subtle">{hint}</div> : null}
200
- </div>
201
- <div className="col-span-12 md:col-span-8">{children}</div>
202
- </div>
203
- )
204
- }
205
-
206
- export default function PayPalSettingsTab() {
207
- const [form, setForm] = useState<PayPalSettingsForm>({
208
- enabled: true,
209
- title: "PayPal",
210
- description: "Pay via PayPal; you can pay with your credit card if you don't have a PayPal account",
211
- displayOn: ["product", "cart", "express", "mini_cart"],
212
- disableButtons: [],
213
- buttonColor: "gold",
214
- buttonShape: "rect",
215
- buttonWidth: "medium",
216
- buttonHeight: 48,
217
- buttonLabel: "paypal",
218
- })
219
- const [loading, setLoading] = useState(false)
220
- const [saving, setSaving] = useState(false)
221
- const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null)
222
- const didInit = useRef(false)
223
-
224
- useEffect(() => {
225
- if (didInit.current) return
226
- didInit.current = true
227
-
228
- ;(async () => {
229
- try {
230
- setLoading(true)
231
-
232
- // BEFORE:
233
- // const r = await fetch("/admin/paypal/settings", {
234
- // credentials: "include",
235
- // headers: { Accept: "application/json" },
236
- // })
237
- // if (!r.ok) return
238
- // const json = await r.json()
239
- //
240
- // AFTER: adminFetch attaches the Bearer token automatically
241
- const json = await adminFetch<{ data?: { paypal_settings?: PayPalSettingsForm }; paypal_settings?: PayPalSettingsForm }>(
242
- "/admin/paypal/settings"
243
- )
244
- const payload = (json?.data ?? json) as any
245
- const saved = payload?.paypal_settings
246
- if (saved && typeof saved === "object") {
247
- setForm((prev) => ({
248
- ...prev,
249
- ...saved,
250
- disableButtons: filterHiddenDisableButtons(saved.disableButtons),
251
- }))
252
- }
253
- } catch {
254
- // Silently ignore load errors — the form will use defaults
255
- } finally {
256
- setLoading(false)
257
- }
258
- })()
259
- }, [])
260
-
261
- async function onSave() {
262
- try {
263
- setSaving(true)
264
- const cleaned = {
265
- ...form,
266
- disableButtons: filterHiddenDisableButtons(form.disableButtons),
267
- }
268
-
269
- // BEFORE:
270
- // const r = await fetch("/admin/paypal/settings", {
271
- // method: "POST",
272
- // credentials: "include",
273
- // headers: { "Content-Type": "application/json", Accept: "application/json" },
274
- // body: JSON.stringify({ paypal_settings: cleaned }),
275
- // })
276
- // if (!r.ok) { ... }
277
- // const json = await r.json().catch(() => null)
278
- //
279
- // AFTER: adminFetch attaches the Bearer token automatically
280
- const json = await adminFetch<{ data?: { paypal_settings?: PayPalSettingsForm }; paypal_settings?: PayPalSettingsForm }>(
281
- "/admin/paypal/settings",
282
- {
283
- method: "POST",
284
- body: { paypal_settings: cleaned as unknown as Record<string, unknown> },
285
- }
286
- )
287
- const payload = (json?.data ?? json) as any
288
- const saved = payload?.paypal_settings
289
- if (saved && typeof saved === "object") {
290
- setForm((prev) => ({
291
- ...prev,
292
- ...saved,
293
- disableButtons: filterHiddenDisableButtons(saved.disableButtons),
294
- }))
295
- }
296
- setToast({ type: "success", message: "Settings saved" })
297
- window.setTimeout(() => setToast(null), 2500)
298
- } catch (e: unknown) {
299
- setToast({
300
- type: "error",
301
- message:
302
- (e instanceof Error ? e.message : "") ||
303
- "Failed to save settings.",
304
- })
305
- window.setTimeout(() => setToast(null), 3500)
306
- } finally {
307
- setSaving(false)
308
- }
309
- }
310
-
311
- const displayOnMap = useMemo(() => new Map(DISPLAY_LOCATION_OPTIONS.map((o) => [o.value, o])), [])
312
-
313
- function toggleMulti<T extends string>(key: keyof PayPalSettingsForm, value: T) {
314
- setForm((prev) => {
315
- const list = (prev[key] as T[]) || []
316
- const exists = list.includes(value)
317
- const next = exists ? list.filter((v) => v !== value) : [...list, value]
318
- return { ...prev, [key]: next }
319
- })
320
- }
321
-
322
- function removeMulti<T extends string>(key: keyof PayPalSettingsForm, value: T) {
323
- setForm((prev) => {
324
- const list = (prev[key] as T[]) || []
325
- return { ...prev, [key]: list.filter((v) => v !== value) }
326
- })
327
- }
328
-
329
- return (
330
- <div className="p-6">
331
- <div className="flex flex-col gap-6">
332
- <div className="flex items-start justify-between gap-4">
333
- <div>
334
- <h1 className="text-xl font-semibold text-ui-fg-base">PayPal Gateway By Easy Payment</h1>
335
- </div>
336
- <div className="flex items-center gap-2">
337
- </div>
338
- </div>
339
-
340
- <PayPalTabs />
341
-
342
- {toast ? (
343
- <div
344
- className="fixed right-6 top-6 z-50 rounded-md border border-ui-border-base bg-ui-bg-base px-4 py-3 text-sm shadow-lg"
345
- role="status"
346
- aria-live="polite"
347
- >
348
- <span className={toast.type === "success" ? "text-ui-fg-base" : "text-ui-fg-error"}>
349
- {toast.message}
350
- </span>
351
- </div>
352
- ) : null}
353
-
354
- {/* PayPal Settings */}
355
- <SectionCard
356
- title="PayPal Settings"
357
- description="Enable PayPal, set title/description, and choose where Smart Buttons appear."
358
- right={(
359
- <div className="flex items-center gap-3">
360
- <button
361
- type="button"
362
- onClick={onSave}
363
- disabled={saving || loading}
364
- className="rounded-md bg-ui-button-neutral px-4 py-2 text-sm font-medium text-ui-fg-on-color shadow-sm hover:opacity-90 disabled:opacity-60"
365
- >
366
- {saving ? "Saving..." : "Save settings"}
367
- </button>
368
- {loading ? <span className="text-sm text-ui-fg-subtle">Loading…</span> : null}
369
- </div>
370
- )}
371
- >
372
- <div className="divide-y divide-ui-border-base">
373
- <FieldRow label="Enable/Disable">
374
- <label className="inline-flex items-center gap-2">
375
- <input
376
- type="checkbox"
377
- checked={form.enabled}
378
- onChange={(e) => setForm((p) => ({ ...p, enabled: e.target.checked }))}
379
- className="h-4 w-4 rounded border-ui-border-base"
380
- />
381
- <span className="text-sm text-ui-fg-base">Enable PayPal</span>
382
- </label>
383
- </FieldRow>
384
-
385
- <FieldRow label="Title">
386
- <input
387
- value={form.title}
388
- onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
389
- className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
390
- placeholder="PayPal"
391
- />
392
- </FieldRow>
393
-
394
- <FieldRow label="Description">
395
- <textarea
396
- value={form.description}
397
- onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
398
- className="min-h-[84px] w-full resize-y rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
399
- placeholder="Pay via PayPal..."
400
- />
401
- </FieldRow>
402
-
403
- <FieldRow
404
- label="Display PayPal Buttons On"
405
- hint="Choose where PayPal Smart Buttons should render."
406
- >
407
- <div className="flex flex-col gap-2">
408
- {/* Selected pills */}
409
- <div className="flex flex-wrap gap-2">
410
- {DISPLAY_LOCATION_OPTIONS.filter((o) => form.displayOn.includes(o.value)).map((o) => (
411
- <Pill
412
- key={o.value}
413
- disabled={o.disabled}
414
- onRemove={
415
- o.disabled
416
- ? undefined
417
- : () => removeMulti<DisplayLocation>("displayOn", o.value)
418
- }
419
- >
420
- {o.label}
421
- {o.disabled ? (
422
- <span className="ml-1 rounded bg-ui-bg-subtle px-1 py-[1px] text-[10px] text-ui-fg-subtle">
423
- Locked
424
- </span>
425
- ) : null}
426
- </Pill>
427
- ))}
428
- {form.displayOn.length === 0 ? (
429
- <span className="text-sm text-ui-fg-subtle">No locations selected.</span>
430
- ) : null}
431
- </div>
432
-
433
- {/* Toggle list */}
434
- <div className="rounded-md border border-ui-border-base p-3">
435
- <div className="grid gap-2 md:grid-cols-2">
436
- {DISPLAY_LOCATION_OPTIONS.map((o) => {
437
- const checked = form.displayOn.includes(o.value)
438
- return (
439
- <label
440
- key={o.value}
441
- className={cx(
442
- "flex items-start gap-2 rounded-md p-2",
443
- o.disabled ? "opacity-60" : "hover:bg-ui-bg-subtle"
444
- )}
445
- >
446
- <input
447
- type="checkbox"
448
- disabled={o.disabled}
449
- checked={checked}
450
- onChange={() => toggleMulti<DisplayLocation>("displayOn", o.value)}
451
- className="mt-0.5 h-4 w-4 rounded border-ui-border-base"
452
- />
453
- <span className="flex flex-col">
454
- <span className="text-sm text-ui-fg-base">{o.label}</span>
455
- {o.hint ? <span className="text-xs text-ui-fg-subtle">{o.hint}</span> : null}
456
- </span>
457
- </label>
458
- )
459
- })}
460
- </div>
461
- </div>
462
- </div>
463
- </FieldRow>
464
- </div>
465
- </SectionCard>
466
-
467
- {/* Button Appearance */}
468
- <SectionCard
469
- title="Button Appearance"
470
- description="Control PayPal Smart Button styling (color/shape/size/label) and optionally disable specific buttons."
471
- >
472
- <div className="divide-y divide-ui-border-base">
473
- <FieldRow
474
- label="Disable Specific Payment Buttons"
475
- hint="Hide individual funding sources (ex: Card, Venmo)."
476
- >
477
- <div className="flex flex-col gap-2">
478
- <div className="flex flex-wrap gap-2">
479
- {filterHiddenDisableButtons(form.disableButtons).map((v) => {
480
- const opt = VISIBLE_DISABLE_BUTTON_OPTIONS.find((o) => o.value === v)
481
- return (
482
- <Pill key={v} onRemove={() => removeMulti<DisabledButton>("disableButtons", v)}>
483
- {opt?.label ?? v}
484
- </Pill>
485
- )
486
- })}
487
- {filterHiddenDisableButtons(form.disableButtons).length === 0 ? (
488
- <span className="text-sm text-ui-fg-subtle">No buttons disabled.</span>
489
- ) : null}
490
- </div>
491
-
492
- <div className="rounded-md border border-ui-border-base p-3">
493
- <div className="grid gap-2 md:grid-cols-2">
494
- {VISIBLE_DISABLE_BUTTON_OPTIONS.map((o) => {
495
- const checked = form.disableButtons.includes(o.value)
496
- return (
497
- <label key={o.value} className="flex items-center gap-2 rounded-md p-2 hover:bg-ui-bg-subtle">
498
- <input
499
- type="checkbox"
500
- checked={checked}
501
- onChange={() => toggleMulti<DisabledButton>("disableButtons", o.value)}
502
- className="h-4 w-4 rounded border-ui-border-base"
503
- />
504
- <span className="text-sm text-ui-fg-base">{o.label}</span>
505
- </label>
506
- )
507
- })}
508
- </div>
509
- </div>
510
- </div>
511
- </FieldRow>
512
-
513
- <FieldRow label="Button Color">
514
- <select
515
- value={form.buttonColor}
516
- onChange={(e) => setForm((p) => ({ ...p, buttonColor: e.target.value as ButtonColor }))}
517
- className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
518
- >
519
- {COLOR_OPTIONS.map((o) => (
520
- <option key={o.value} value={o.value}>
521
- {o.label}
522
- </option>
523
- ))}
524
- </select>
525
- </FieldRow>
526
-
527
- <FieldRow label="Button Shape">
528
- <select
529
- value={form.buttonShape}
530
- onChange={(e) => setForm((p) => ({ ...p, buttonShape: e.target.value as ButtonShape }))}
531
- className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
532
- >
533
- {SHAPE_OPTIONS.map((o) => (
534
- <option key={o.value} value={o.value}>
535
- {o.label}
536
- </option>
537
- ))}
538
- </select>
539
- </FieldRow>
540
-
541
- <FieldRow label="Button Width">
542
- <select
543
- value={form.buttonWidth}
544
- onChange={(e) => setForm((p) => ({ ...p, buttonWidth: e.target.value as ButtonWidth }))}
545
- className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
546
- >
547
- {WIDTH_OPTIONS.map((o) => (
548
- <option key={o.value} value={o.value}>
549
- {o.label}
550
- </option>
551
- ))}
552
- </select>
553
- </FieldRow>
554
-
555
- <FieldRow label="Button Height">
556
- <select
557
- value={String(form.buttonHeight)}
558
- onChange={(e) => setForm((p) => ({ ...p, buttonHeight: Number(e.target.value) }))}
559
- className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
560
- >
561
- {HEIGHT_OPTIONS.map((h) => (
562
- <option key={h} value={h}>
563
- {h} px
564
- </option>
565
- ))}
566
- </select>
567
- </FieldRow>
568
-
569
- <FieldRow label="Button Label">
570
- <select
571
- value={form.buttonLabel}
572
- onChange={(e) => setForm((p) => ({ ...p, buttonLabel: e.target.value as ButtonLabel }))}
573
- className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
574
- >
575
- {LABEL_OPTIONS.map((o) => (
576
- <option key={o.value} value={o.value}>
577
- {o.label}
578
- </option>
579
- ))}
580
- </select>
581
- </FieldRow>
582
- </div>
583
- </SectionCard>
584
-
585
- {/* Optional: preview block (pure UI) */}
586
- <div className="mt-6 rounded-md border border-ui-border-base bg-ui-bg-subtle p-4">
587
- <div className="text-sm font-medium text-ui-fg-base">Preview (UI only)</div>
588
- <div className="mt-2 text-sm text-ui-fg-subtle">
589
- Color: <span className="text-ui-fg-base">{form.buttonColor}</span> · Shape:{" "}
590
- <span className="text-ui-fg-base">{form.buttonShape}</span> · Width:{" "}
591
- <span className="text-ui-fg-base">{form.buttonWidth}</span> · Height:{" "}
592
- <span className="text-ui-fg-base">{form.buttonHeight}px</span> · Label:{" "}
593
- <span className="text-ui-fg-base">{form.buttonLabel}</span>
594
- </div>
595
- </div>
596
- </div>
597
- </div>
598
- )
599
- }
1
+ import React, { useEffect, useRef, useState } from "react"
2
+ import PayPalTabs from "../_components/Tabs"
3
+
4
+ type AdminFetchOptions = {
5
+ method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
6
+ body?: Record<string, unknown>
7
+ query?: Record<string, string>
8
+ }
9
+
10
+ async function adminFetch<T = unknown>(path: string, opts: AdminFetchOptions = {}): Promise<T> {
11
+ const { method = "GET", body, query } = opts
12
+ let url = path
13
+ if (query && Object.keys(query).length > 0) {
14
+ const params = new URLSearchParams(query)
15
+ url = `${path}?${params.toString()}`
16
+ }
17
+ const headers: Record<string, string> = { Accept: "application/json" }
18
+ if (body !== undefined) headers["Content-Type"] = "application/json"
19
+ if (typeof window !== "undefined") {
20
+ const token = (window as any).__medusa__?.token
21
+ if (token) headers["Authorization"] = `Bearer ${token}`
22
+ }
23
+ const res = await fetch(url, {
24
+ method, headers, credentials: "include",
25
+ body: body !== undefined ? JSON.stringify(body) : undefined,
26
+ })
27
+ const text = await res.text().catch(() => "")
28
+ if (!res.ok) {
29
+ if (res.status === 401) throw new Error("Unauthorized (401) - session may have expired. Please reload and log in again.")
30
+ if (res.status === 403) throw new Error("Forbidden (403) - you do not have permission to perform this action.")
31
+ throw new Error(text || `Request failed with status ${res.status}`)
32
+ }
33
+ if (!text) return {} as T
34
+ try { return JSON.parse(text) as T } catch { return {} as T }
35
+ }
36
+
37
+ type ButtonColor = "gold" | "blue" | "silver" | "black" | "white"
38
+ type ButtonShape = "rect" | "pill"
39
+ type ButtonWidth = "small" | "medium" | "large" | "responsive"
40
+ type ButtonLabel = "paypal" | "checkout" | "buynow" | "pay"
41
+
42
+ type DisabledButton =
43
+ | "paypal"
44
+ | "paylater"
45
+ | "card"
46
+ | "venmo"
47
+ | "applepay"
48
+ | "googlepay"
49
+
50
+ type PayPalSettingsForm = {
51
+ enabled: boolean
52
+ title: string
53
+ description: string
54
+ disableButtons: DisabledButton[]
55
+ buttonColor: ButtonColor
56
+ buttonShape: ButtonShape
57
+ buttonWidth: ButtonWidth
58
+ buttonHeight: number
59
+ buttonLabel: ButtonLabel
60
+ }
61
+
62
+ const DISABLE_BUTTON_OPTIONS: { value: DisabledButton; label: string }[] = [
63
+ { value: "paypal", label: "PayPal" },
64
+ { value: "paylater", label: "Pay Later" },
65
+ { value: "card", label: "Debit / Credit Card" },
66
+ { value: "venmo", label: "Venmo" },
67
+ { value: "applepay", label: "Apple Pay" },
68
+ { value: "googlepay", label: "Google Pay" },
69
+ ]
70
+
71
+ const HIDDEN_DISABLE_BUTTONS = new Set<DisabledButton>(["applepay", "googlepay", "paylater"])
72
+
73
+ const VISIBLE_DISABLE_BUTTON_OPTIONS = DISABLE_BUTTON_OPTIONS.filter(
74
+ (option) => !HIDDEN_DISABLE_BUTTONS.has(option.value)
75
+ )
76
+
77
+ function filterHiddenDisableButtons(list: DisabledButton[] = []) {
78
+ return list.filter((value) => !HIDDEN_DISABLE_BUTTONS.has(value))
79
+ }
80
+
81
+ const COLOR_OPTIONS: { value: ButtonColor; label: string }[] = [
82
+ { value: "gold", label: "Gold (Recommended)" },
83
+ { value: "blue", label: "Blue" },
84
+ { value: "silver", label: "Silver" },
85
+ { value: "black", label: "Black" },
86
+ { value: "white", label: "White" },
87
+ ]
88
+
89
+ const SHAPE_OPTIONS: { value: ButtonShape; label: string }[] = [
90
+ { value: "rect", label: "Rect (Recommended)" },
91
+ { value: "pill", label: "Pill" },
92
+ ]
93
+
94
+ const WIDTH_OPTIONS: { value: ButtonWidth; label: string }[] = [
95
+ { value: "small", label: "Small" },
96
+ { value: "medium", label: "Medium" },
97
+ { value: "large", label: "Large" },
98
+ { value: "responsive", label: "Responsive" },
99
+ ]
100
+
101
+ const HEIGHT_OPTIONS: number[] = [32, 36, 40, 44, 48, 52, 56]
102
+
103
+ const LABEL_OPTIONS: { value: ButtonLabel; label: string }[] = [
104
+ { value: "paypal", label: "PayPal (Recommended)" },
105
+ { value: "checkout", label: "Checkout" },
106
+ { value: "buynow", label: "Buy Now" },
107
+ { value: "pay", label: "Pay" },
108
+ ]
109
+
110
+ function cx(...parts: Array<string | false | undefined | null>) {
111
+ return parts.filter(Boolean).join(" ")
112
+ }
113
+
114
+ function Pill({
115
+ children,
116
+ onRemove,
117
+ disabled,
118
+ }: {
119
+ children: React.ReactNode
120
+ onRemove?: () => void
121
+ disabled?: boolean
122
+ }) {
123
+ return (
124
+ <span
125
+ className={cx(
126
+ "inline-flex items-center gap-1 rounded-md border px-2 py-1 text-sm",
127
+ disabled ? "opacity-60" : "opacity-100"
128
+ )}
129
+ >
130
+ {children}
131
+ {onRemove ? (
132
+ <button
133
+ type="button"
134
+ onClick={onRemove}
135
+ className="ml-1 rounded px-1 text-ui-fg-subtle hover:text-ui-fg-base"
136
+ aria-label="Remove"
137
+ >
138
+ ×
139
+ </button>
140
+ ) : null}
141
+ </span>
142
+ )
143
+ }
144
+
145
+ function SectionCard({
146
+ title,
147
+ description,
148
+ children,
149
+ right,
150
+ }: {
151
+ title: string
152
+ description?: string
153
+ children: React.ReactNode
154
+ right?: React.ReactNode
155
+ }) {
156
+ return (
157
+ <div className="rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm">
158
+ <div className="flex items-start justify-between gap-4 border-b border-ui-border-base p-4">
159
+ <div>
160
+ <div className="text-base font-semibold text-ui-fg-base">{title}</div>
161
+ {description ? <div className="mt-1 text-sm text-ui-fg-subtle">{description}</div> : null}
162
+ </div>
163
+ {right}
164
+ </div>
165
+ <div className="p-4">{children}</div>
166
+ </div>
167
+ )
168
+ }
169
+
170
+ function FieldRow({
171
+ label,
172
+ hint,
173
+ children,
174
+ }: {
175
+ label: string
176
+ hint?: React.ReactNode
177
+ children: React.ReactNode
178
+ }) {
179
+ return (
180
+ <div className="grid grid-cols-12 items-start gap-4 py-3">
181
+ <div className="col-span-12 md:col-span-4">
182
+ <div className="text-sm font-medium text-ui-fg-base">{label}</div>
183
+ {hint ? <div className="mt-1 text-xs text-ui-fg-subtle">{hint}</div> : null}
184
+ </div>
185
+ <div className="col-span-12 md:col-span-8">{children}</div>
186
+ </div>
187
+ )
188
+ }
189
+
190
+ export default function PayPalSettingsTab() {
191
+ const [form, setForm] = useState<PayPalSettingsForm>({
192
+ enabled: true,
193
+ title: "PayPal",
194
+ description: "Pay via PayPal; you can pay with your credit card if you don't have a PayPal account",
195
+ disableButtons: [],
196
+ buttonColor: "gold",
197
+ buttonShape: "rect",
198
+ buttonWidth: "medium",
199
+ buttonHeight: 48,
200
+ buttonLabel: "paypal",
201
+ })
202
+ const [loading, setLoading] = useState(false)
203
+ const [saving, setSaving] = useState(false)
204
+ const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null)
205
+ const didInit = useRef(false)
206
+
207
+ useEffect(() => {
208
+ if (didInit.current) return
209
+ didInit.current = true
210
+
211
+ ;(async () => {
212
+ try {
213
+ setLoading(true)
214
+
215
+ // BEFORE:
216
+ // const r = await fetch("/admin/paypal/settings", {
217
+ // credentials: "include",
218
+ // headers: { Accept: "application/json" },
219
+ // })
220
+ // if (!r.ok) return
221
+ // const json = await r.json()
222
+ //
223
+ // AFTER: adminFetch attaches the Bearer token automatically
224
+ const json = await adminFetch<{ data?: { paypal_settings?: PayPalSettingsForm }; paypal_settings?: PayPalSettingsForm }>(
225
+ "/admin/paypal/settings"
226
+ )
227
+ const payload = (json?.data ?? json) as any
228
+ const saved = payload?.paypal_settings
229
+ if (saved && typeof saved === "object") {
230
+ setForm((prev) => ({
231
+ ...prev,
232
+ ...saved,
233
+ disableButtons: filterHiddenDisableButtons(saved.disableButtons),
234
+ }))
235
+ }
236
+ } catch {
237
+ // Silently ignore load errors — the form will use defaults
238
+ } finally {
239
+ setLoading(false)
240
+ }
241
+ })()
242
+ }, [])
243
+
244
+ async function onSave() {
245
+ try {
246
+ setSaving(true)
247
+ const cleaned = {
248
+ ...form,
249
+ disableButtons: filterHiddenDisableButtons(form.disableButtons),
250
+ }
251
+
252
+ // BEFORE:
253
+ // const r = await fetch("/admin/paypal/settings", {
254
+ // method: "POST",
255
+ // credentials: "include",
256
+ // headers: { "Content-Type": "application/json", Accept: "application/json" },
257
+ // body: JSON.stringify({ paypal_settings: cleaned }),
258
+ // })
259
+ // if (!r.ok) { ... }
260
+ // const json = await r.json().catch(() => null)
261
+ //
262
+ // AFTER: adminFetch attaches the Bearer token automatically
263
+ const json = await adminFetch<{ data?: { paypal_settings?: PayPalSettingsForm }; paypal_settings?: PayPalSettingsForm }>(
264
+ "/admin/paypal/settings",
265
+ {
266
+ method: "POST",
267
+ body: { paypal_settings: cleaned as unknown as Record<string, unknown> },
268
+ }
269
+ )
270
+ const payload = (json?.data ?? json) as any
271
+ const saved = payload?.paypal_settings
272
+ if (saved && typeof saved === "object") {
273
+ setForm((prev) => ({
274
+ ...prev,
275
+ ...saved,
276
+ disableButtons: filterHiddenDisableButtons(saved.disableButtons),
277
+ }))
278
+ }
279
+ setToast({ type: "success", message: "Settings saved" })
280
+ window.setTimeout(() => setToast(null), 2500)
281
+ } catch (e: unknown) {
282
+ setToast({
283
+ type: "error",
284
+ message:
285
+ (e instanceof Error ? e.message : "") ||
286
+ "Failed to save settings.",
287
+ })
288
+ window.setTimeout(() => setToast(null), 3500)
289
+ } finally {
290
+ setSaving(false)
291
+ }
292
+ }
293
+
294
+ function toggleMulti<T extends string>(key: keyof PayPalSettingsForm, value: T) {
295
+ setForm((prev) => {
296
+ const list = (prev[key] as T[]) || []
297
+ const exists = list.includes(value)
298
+ const next = exists ? list.filter((v) => v !== value) : [...list, value]
299
+ return { ...prev, [key]: next }
300
+ })
301
+ }
302
+
303
+ function removeMulti<T extends string>(key: keyof PayPalSettingsForm, value: T) {
304
+ setForm((prev) => {
305
+ const list = (prev[key] as T[]) || []
306
+ return { ...prev, [key]: list.filter((v) => v !== value) }
307
+ })
308
+ }
309
+
310
+ return (
311
+ <div className="p-6">
312
+ <div className="flex flex-col gap-6">
313
+ <div className="flex items-start justify-between gap-4">
314
+ <div>
315
+ <h1 className="text-xl font-semibold text-ui-fg-base">PayPal Gateway By Easy Payment</h1>
316
+ </div>
317
+ <div className="flex items-center gap-2">
318
+ </div>
319
+ </div>
320
+
321
+ <PayPalTabs />
322
+
323
+ {toast ? (
324
+ <div
325
+ className="fixed right-6 top-6 z-50 rounded-md border border-ui-border-base bg-ui-bg-base px-4 py-3 text-sm shadow-lg"
326
+ role="status"
327
+ aria-live="polite"
328
+ >
329
+ <span className={toast.type === "success" ? "text-ui-fg-base" : "text-ui-fg-error"}>
330
+ {toast.message}
331
+ </span>
332
+ </div>
333
+ ) : null}
334
+
335
+ {/* PayPal Settings */}
336
+ <SectionCard
337
+ title="PayPal Settings"
338
+ description="Enable PayPal and configure checkout title."
339
+ right={(
340
+ <div className="flex items-center gap-3">
341
+ <button
342
+ type="button"
343
+ onClick={onSave}
344
+ disabled={saving || loading}
345
+ className="rounded-md bg-ui-button-neutral px-4 py-2 text-sm font-medium text-ui-fg-on-color shadow-sm hover:opacity-90 disabled:opacity-60"
346
+ >
347
+ {saving ? "Saving..." : "Save settings"}
348
+ </button>
349
+ {loading ? <span className="text-sm text-ui-fg-subtle">Loading…</span> : null}
350
+ </div>
351
+ )}
352
+ >
353
+ <div className="divide-y divide-ui-border-base">
354
+ <FieldRow label="Enable/Disable">
355
+ <label className="inline-flex items-center gap-2">
356
+ <input
357
+ type="checkbox"
358
+ checked={form.enabled}
359
+ onChange={(e) => setForm((p) => ({ ...p, enabled: e.target.checked }))}
360
+ className="h-4 w-4 rounded border-ui-border-base"
361
+ />
362
+ <span className="text-sm text-ui-fg-base">Enable PayPal</span>
363
+ </label>
364
+ </FieldRow>
365
+
366
+ <FieldRow label="Title">
367
+ <input
368
+ value={form.title}
369
+ onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
370
+ className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
371
+ placeholder="PayPal"
372
+ />
373
+ </FieldRow>
374
+
375
+ </div>
376
+ </SectionCard>
377
+
378
+ {/* Button Appearance */}
379
+ <SectionCard
380
+ title="Button Appearance"
381
+ description="Control PayPal Smart Button styling (color/shape/size/label) and optionally disable specific buttons."
382
+ >
383
+ <div className="divide-y divide-ui-border-base">
384
+ <FieldRow
385
+ label="Disable Specific Payment Buttons"
386
+ hint="Hide individual funding sources (ex: Card, Venmo)."
387
+ >
388
+ <div className="flex flex-col gap-2">
389
+ <div className="flex flex-wrap gap-2">
390
+ {filterHiddenDisableButtons(form.disableButtons).map((v) => {
391
+ const opt = VISIBLE_DISABLE_BUTTON_OPTIONS.find((o) => o.value === v)
392
+ return (
393
+ <Pill key={v} onRemove={() => removeMulti<DisabledButton>("disableButtons", v)}>
394
+ {opt?.label ?? v}
395
+ </Pill>
396
+ )
397
+ })}
398
+ {filterHiddenDisableButtons(form.disableButtons).length === 0 ? (
399
+ <span className="text-sm text-ui-fg-subtle">No buttons disabled.</span>
400
+ ) : null}
401
+ </div>
402
+
403
+ <div className="rounded-md border border-ui-border-base p-3">
404
+ <div className="grid gap-2 md:grid-cols-2">
405
+ {VISIBLE_DISABLE_BUTTON_OPTIONS.map((o) => {
406
+ const checked = form.disableButtons.includes(o.value)
407
+ return (
408
+ <label key={o.value} className="flex items-center gap-2 rounded-md p-2 hover:bg-ui-bg-subtle">
409
+ <input
410
+ type="checkbox"
411
+ checked={checked}
412
+ onChange={() => toggleMulti<DisabledButton>("disableButtons", o.value)}
413
+ className="h-4 w-4 rounded border-ui-border-base"
414
+ />
415
+ <span className="text-sm text-ui-fg-base">{o.label}</span>
416
+ </label>
417
+ )
418
+ })}
419
+ </div>
420
+ </div>
421
+ </div>
422
+ </FieldRow>
423
+
424
+ <FieldRow label="Button Color">
425
+ <select
426
+ value={form.buttonColor}
427
+ onChange={(e) => setForm((p) => ({ ...p, buttonColor: e.target.value as ButtonColor }))}
428
+ className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
429
+ >
430
+ {COLOR_OPTIONS.map((o) => (
431
+ <option key={o.value} value={o.value}>
432
+ {o.label}
433
+ </option>
434
+ ))}
435
+ </select>
436
+ </FieldRow>
437
+
438
+ <FieldRow label="Button Shape">
439
+ <select
440
+ value={form.buttonShape}
441
+ onChange={(e) => setForm((p) => ({ ...p, buttonShape: e.target.value as ButtonShape }))}
442
+ className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
443
+ >
444
+ {SHAPE_OPTIONS.map((o) => (
445
+ <option key={o.value} value={o.value}>
446
+ {o.label}
447
+ </option>
448
+ ))}
449
+ </select>
450
+ </FieldRow>
451
+
452
+ <FieldRow label="Button Width">
453
+ <select
454
+ value={form.buttonWidth}
455
+ onChange={(e) => setForm((p) => ({ ...p, buttonWidth: e.target.value as ButtonWidth }))}
456
+ className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
457
+ >
458
+ {WIDTH_OPTIONS.map((o) => (
459
+ <option key={o.value} value={o.value}>
460
+ {o.label}
461
+ </option>
462
+ ))}
463
+ </select>
464
+ </FieldRow>
465
+
466
+ <FieldRow label="Button Height">
467
+ <select
468
+ value={String(form.buttonHeight)}
469
+ onChange={(e) => setForm((p) => ({ ...p, buttonHeight: Number(e.target.value) }))}
470
+ className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
471
+ >
472
+ {HEIGHT_OPTIONS.map((h) => (
473
+ <option key={h} value={h}>
474
+ {h} px
475
+ </option>
476
+ ))}
477
+ </select>
478
+ </FieldRow>
479
+
480
+ <FieldRow label="Button Label">
481
+ <select
482
+ value={form.buttonLabel}
483
+ onChange={(e) => setForm((p) => ({ ...p, buttonLabel: e.target.value as ButtonLabel }))}
484
+ className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive"
485
+ >
486
+ {LABEL_OPTIONS.map((o) => (
487
+ <option key={o.value} value={o.value}>
488
+ {o.label}
489
+ </option>
490
+ ))}
491
+ </select>
492
+ </FieldRow>
493
+ </div>
494
+ </SectionCard>
495
+
496
+ {/* Optional: preview block (pure UI) */}
497
+ <div className="mt-6 rounded-md border border-ui-border-base bg-ui-bg-subtle p-4">
498
+ <div className="text-sm font-medium text-ui-fg-base">Preview (UI only)</div>
499
+ <div className="mt-2 text-sm text-ui-fg-subtle">
500
+ Color: <span className="text-ui-fg-base">{form.buttonColor}</span> · Shape:{" "}
501
+ <span className="text-ui-fg-base">{form.buttonShape}</span> · Width:{" "}
502
+ <span className="text-ui-fg-base">{form.buttonWidth}</span> · Height:{" "}
503
+ <span className="text-ui-fg-base">{form.buttonHeight}px</span> · Label:{" "}
504
+ <span className="text-ui-fg-base">{form.buttonLabel}</span>
505
+ </div>
506
+ </div>
507
+ </div>
508
+ </div>
509
+ )
510
+ }