@easypayment/medusa-paypal 0.2.6 → 0.2.8

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