@easypayment/medusa-paypal-ui 1.1.1 โ†’ 1.2.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.
package/README.md CHANGED
@@ -1,760 +1,782 @@
1
- # PayPal for Medusa Frontend UI
2
-
3
- **PayPal checkout UI for Medusa v2 storefronts โ€” Smart Buttons, Advanced Card Fields**
4
-
5
- [![npm version](https://img.shields.io/npm/v/@easypayment/medusa-paypal-ui?color=blue&label=npm)](https://www.npmjs.com/package/@easypayment/medusa-paypal-ui)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
7
- [![Medusa v2](https://img.shields.io/badge/Medusa-v2-9b59b6)](https://medusajs.com)
8
- [![Next.js](https://img.shields.io/badge/Next.js-14%2B-black)](https://nextjs.org)
9
-
10
- ---
11
-
12
- ## ๐Ÿ“‹ Table of Contents
13
-
14
- - [๐Ÿ“ฆ Overview](#-overview)
15
- - [โœ… Requirements](#-requirements)
16
- - [๐Ÿš€ Installation](#-installation)
17
- - [๐Ÿ”‘ Environment Variables](#-environment-variables)
18
- - [๐Ÿ”— Integration Guide](#-integration-guide)
19
- - [Step 1 โ€” Add the import](#step-1--add-the-import)
20
- - [Step 2 โ€” Add PayPal helpers and state](#step-2--add-paypal-helpers-and-state)
21
- - [Step 3 โ€” Load PayPal config](#step-3--load-paypal-config)
22
- - [Step 4 โ€” Update setPaymentMethod](#step-4--update-setpaymentmethod)
23
- - [Step 5 โ€” Filter the payment method list](#step-5--filter-the-payment-method-list)
24
- - [Step 6 โ€” Inject admin-configured titles](#step-6--inject-admin-configured-titles)
25
- - [Step 7 โ€” Render the PayPal UI](#step-7--render-the-paypal-ui)
26
- - [Step 8 โ€” Disable the Continue button](#step-8--disable-the-continue-button)
27
- - [Step 9 โ€” Fix the summary label](#step-9--fix-the-summary-label)
28
- - [๐Ÿ“„ Complete File](#-complete-file)
29
- - [๐Ÿงช Testing](#-testing)
30
- - [๐Ÿ“„ License](#-license)
31
-
32
- ---
33
-
34
- ## ๐Ÿ“ฆ Overview
35
-
36
- `@easypayment/medusa-paypal-ui` is the **storefront UI package** that connects your Next.js (App Router) storefront to the `@easypayment/medusa-paypal` backend plugin. It ships the PayPal adapter used inside your checkout payment step โ€” your storefront adds the adapter, provider filtering, and backend config handling to the existing Medusa payment UI.
37
-
38
- | Feature | Details |
39
- |---|---|
40
- | ๐Ÿ”ต **PayPal Smart Buttons** | Wallet-based checkout via `pp_paypal_paypal` |
41
- | ๐Ÿ’ณ **Advanced Card Fields** | Hosted PCI-compliant advanced credit card inputs via `pp_paypal_card_paypal_card` |
42
- | ๐Ÿ›  **Admin-driven config** | Enable/disable providers and set labels from Medusa Admin |
43
- | โšก **Built-in UX** | Smart Buttons and Advanced Card UI rendered by `MedusaNextPayPalAdapter` |
44
- | ๐Ÿ”„ **Storefront-controlled flow** | Your payment step controls session creation, loading states, and `placeOrder` |
45
-
46
- ---
47
-
48
- ## โœ… Requirements
49
-
50
- - **Node.js** 18+
51
- - **Next.js** 14+ with App Router
52
- - **`@easypayment/medusa-paypal`** installed and running on your Medusa server
53
- - A PayPal account connected in **Medusa Admin โ†’ Settings โ†’ PayPal โ†’ PayPal Connection**
54
-
55
- ---
56
-
57
- ## ๐Ÿš€ Installation
58
-
59
- **In your storefront directory**, run:
60
-
61
- ```bash
62
- npm install @easypayment/medusa-paypal-ui
63
- ```
64
-
65
- ---
66
-
67
- ## ๐Ÿ”‘ Environment Variables
68
-
69
- Add the following to your storefront `.env.local`. Use separate values for development and production.
70
-
71
- ```env
72
- NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
73
- NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_...
74
- ```
75
-
76
- > **Where to get the publishable key:**
77
- > Medusa Admin โ†’ **Settings โ†’ API Key Management โ†’ Create API Key**
78
-
79
- ---
80
-
81
- ## ๐Ÿ”— Integration Guide
82
-
83
- All changes in this guide are made to **one single file** in your storefront:
84
-
85
- ```
86
- src/modules/checkout/components/payment/index.tsx
87
- ```
88
-
89
- Open that file and follow each step in order.
90
-
91
- > **Prefer to copy-paste the whole file?** Skip straight to [Complete File](#-complete-file) and replace the entire contents in one go. The complete file has all 9 steps already applied.
92
-
93
- ---
94
-
95
- ### Step 1 โ€” Add the import
96
-
97
- **Where:** At the very top of the file, alongside your other imports.
98
-
99
- ```tsx
100
- import { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"
101
- ```
102
-
103
- ---
104
-
105
- ### Step 2 โ€” Add PayPal helpers and state
106
-
107
- **Where:** At the top of the file, outside the component โ€” add the constants. Inside the `Payment` component, add the `useState` lines alongside your other state declarations.
108
-
109
- ```tsx
110
- // Outside the component โ€” add these constants
111
- const PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
112
- const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
113
- const PAYPAL_PROVIDER_IDS = [PAYPAL_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID]
114
-
115
- const isPayPal = (id: string) => PAYPAL_PROVIDER_IDS.includes(id)
116
- ```
117
-
118
- ```tsx
119
- // Inside the Payment component โ€” add alongside your other useState declarations
120
- const [paypalEnabled, setPaypalEnabled] = useState(true)
121
- const [paypalTitle, setPaypalTitle] = useState("PayPal")
122
- const [cardEnabled, setCardEnabled] = useState(true)
123
- const [cardTitle, setCardTitle] = useState("Credit or Debit Card")
124
- const [paypalLoading, setPaypalLoading] = useState(false)
125
- ```
126
-
127
- ---
128
-
129
- ### Step 3 โ€” Load PayPal config
130
-
131
- **Where:** Inside the `Payment` component, alongside your other `useEffect` hooks.
132
-
133
- This fetches PayPal settings from your backend whenever the payment step is opened, so the UI always reflects the latest admin configuration.
134
-
135
- ```tsx
136
- useEffect(() => {
137
- if (!isOpen) return
138
-
139
- const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
140
- if (!backendUrl) return
141
-
142
- const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
143
- const controller = new AbortController()
144
-
145
- const loadPayPalConfig = async () => {
146
- try {
147
- const response = await fetch(`${backendUrl}/store/paypal/config`, {
148
- headers: key ? { "x-publishable-api-key": key } : {},
149
- signal: controller.signal,
150
- })
151
-
152
- if (response.status === 403) {
153
- setPaypalEnabled(false)
154
- setCardEnabled(false)
155
- return
156
- }
157
-
158
- if (!response.ok) return
159
-
160
- const config = await response.json()
161
-
162
- if (typeof config?.paypal_enabled === "boolean") setPaypalEnabled(config.paypal_enabled)
163
- if (typeof config?.paypal_title === "string" && config.paypal_title) setPaypalTitle(config.paypal_title)
164
- if (typeof config?.card_enabled === "boolean") setCardEnabled(config.card_enabled)
165
- if (typeof config?.card_title === "string" && config.card_title) setCardTitle(config.card_title)
166
- } catch (err) {
167
- if ((err as Error).name !== "AbortError") setPaypalLoading(false)
168
- }
169
- }
170
-
171
- void loadPayPalConfig()
172
- return () => controller.abort()
173
- }, [isOpen])
174
- ```
175
-
176
- ---
177
-
178
- ### Step 4 โ€” Update setPaymentMethod
179
-
180
- **Where:** Inside the `Payment` component. Find your existing `setPaymentMethod` function and **replace it entirely** with the version below.
181
-
182
- The key addition is `paypalLoading` โ€” it shows a loading indicator while the PayPal payment session is being created in the background.
183
-
184
- ```tsx
185
- const setPaymentMethod = async (method: string) => {
186
- setError(null)
187
- setSelectedPaymentMethod(method)
188
-
189
- if (!isStripeLike(method) && !isPayPal(method)) return
190
-
191
- if (isPayPal(method)) setPaypalLoading(true)
192
-
193
- try {
194
- await initiatePaymentSession(cart, { provider_id: method })
195
- } finally {
196
- if (isPayPal(method)) setPaypalLoading(false)
197
- }
198
- }
199
- ```
200
-
201
- ---
202
-
203
- ### Step 5 โ€” Filter the payment method list
204
-
205
- **Where:** Inside the `Payment` component, alongside your other `useMemo` declarations โ€” add this before the `return` statement.
206
-
207
- This hides PayPal or Card from the list if they have been disabled in Medusa Admin.
208
-
209
- ```tsx
210
- const filteredPaymentMethods = useMemo(
211
- () =>
212
- availablePaymentMethods.filter((paymentMethod) => {
213
- if (paymentMethod.id === PAYPAL_PROVIDER_ID) return paypalEnabled
214
- if (paymentMethod.id === PAYPAL_CARD_PROVIDER_ID) return cardEnabled
215
- return true
216
- }),
217
- [availablePaymentMethods, cardEnabled, paypalEnabled],
218
- )
219
- ```
220
-
221
- Then in your JSX, find where you render `availablePaymentMethods.map(...)` and **replace** `availablePaymentMethods` with `filteredPaymentMethods`:
222
-
223
- ```tsx
224
- // Before
225
- availablePaymentMethods.map((paymentMethod) => ( ... ))
226
-
227
- // After
228
- filteredPaymentMethods.map((paymentMethod) => ( ... ))
229
- ```
230
-
231
- ---
232
-
233
- ### Step 6 โ€” Inject admin-configured titles
234
-
235
- **Where:** Inside the `.map()` loop from Step 5, find your `<PaymentContainer>` component and **replace** its `paymentInfoMap` prop with the version below.
236
-
237
- This makes the radio button labels show the titles configured in Medusa Admin instead of hardcoded defaults.
238
-
239
- ```tsx
240
- <PaymentContainer
241
- paymentInfoMap={{
242
- ...paymentInfoMap,
243
- ...(paymentMethod.id === PAYPAL_PROVIDER_ID
244
- ? { [paymentMethod.id]: { ...(paymentInfoMap[paymentMethod.id] || {}), title: paypalTitle } }
245
- : {}),
246
- ...(paymentMethod.id === PAYPAL_CARD_PROVIDER_ID
247
- ? { [paymentMethod.id]: { ...(paymentInfoMap[paymentMethod.id] || {}), title: cardTitle } }
248
- : {}),
249
- }}
250
- paymentProviderId={paymentMethod.id}
251
- selectedPaymentOptionId={selectedPaymentMethod}
252
- />
253
- ```
254
-
255
- ---
256
-
257
- ### Step 7 โ€” Render the PayPal UI
258
-
259
- **Where:** In the JSX, immediately after the closing `</RadioGroup>` tag.
260
-
261
- The first block shows a loading spinner while the session is being set up. The second block renders the PayPal buttons or card fields once the session is ready.
262
-
263
- ```tsx
264
- {/* Loading state while PayPal session is being created */}
265
- {isPayPal(selectedPaymentMethod) && paypalLoading && (
266
- <div>Setting up payment...</div>
267
- )}
268
-
269
- {/* PayPal buttons or card fields */}
270
- {isPayPal(selectedPaymentMethod) && !paypalLoading && (
271
- <MedusaNextPayPalAdapter
272
- cartId={cart.id}
273
- selectedProviderId={selectedPaymentMethod}
274
- baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
275
- publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
276
- onSuccess={async () => {
277
- await placeOrder(cart.id)
278
- }}
279
- onError={(message) => setError(message)}
280
- />
281
- )}
282
- ```
283
-
284
- ---
285
-
286
- ### Step 8 โ€” Disable the Continue button
287
-
288
- **Where:** In the JSX, find your existing `<Button>` with `data-testid="submit-payment-button"` and **add** `isPayPal(selectedPaymentMethod)` to its `disabled` prop.
289
-
290
- PayPal handles its own checkout action, so the "Continue to review" button must be hidden from the flow when PayPal is selected.
291
-
292
- ```tsx
293
- <Button
294
- size="large"
295
- className="mt-6"
296
- onClick={handleSubmit}
297
- isLoading={isLoading}
298
- disabled={
299
- (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
300
- (!selectedPaymentMethod && !paidByGiftcard) ||
301
- isPayPal(selectedPaymentMethod) // ๐Ÿ‘ˆ add this line
302
- }
303
- data-testid="submit-payment-button"
304
- >
305
- {!activeSession && isStripeLike(selectedPaymentMethod)
306
- ? "Enter card details"
307
- : "Continue to review"}
308
- </Button>
309
- ```
310
-
311
- ---
312
-
313
- ### Step 9 โ€” Fix the summary label
314
-
315
- **Where:** In the collapsed summary view (shown after the customer has completed the payment step). Find the `<Text>` with `data-testid="payment-method-summary"` and **replace its content** with the version below.
316
-
317
- This shows the admin-configured title instead of a hardcoded or missing label.
318
-
319
- ```tsx
320
- <Text
321
- className="txt-medium text-ui-fg-subtle"
322
- data-testid="payment-method-summary"
323
- >
324
- {activeSession?.provider_id === "pp_paypal_paypal"
325
- ? paypalTitle
326
- : activeSession?.provider_id === "pp_paypal_card_paypal_card"
327
- ? cardTitle
328
- : paymentInfoMap[activeSession?.provider_id]?.title ||
329
- activeSession?.provider_id}
330
- </Text>
331
- ```
332
-
333
- ---
334
-
335
- ## ๐Ÿ“„ Complete File
336
-
337
- If you prefer to copy-paste the entire file at once, replace the full contents of `src/modules/checkout/components/payment/index.tsx` with the following:
338
-
339
- ```tsx
340
- "use client"
341
-
342
- import { RadioGroup } from "@headlessui/react"
343
- import { initiatePaymentSession, placeOrder } from "@lib/data/cart"
344
- import { isStripeLike, paymentInfoMap } from "@lib/constants"
345
- import { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"
346
- import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
347
- import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
348
- import ErrorMessage from "@modules/checkout/components/error-message"
349
- import PaymentContainer, {
350
- StripeCardContainer,
351
- } from "@modules/checkout/components/payment-container"
352
- import Divider from "@modules/common/components/divider"
353
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
354
- import { useCallback, useEffect, useMemo, useState } from "react"
355
-
356
- const PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
357
- const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
358
- const PAYPAL_PROVIDER_IDS = [PAYPAL_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID]
359
-
360
- const isPayPal = (id: string) => PAYPAL_PROVIDER_IDS.includes(id)
361
-
362
- const Payment = ({
363
- cart,
364
- availablePaymentMethods,
365
- }: {
366
- cart: any
367
- availablePaymentMethods: any[]
368
- }) => {
369
- const activeSession = cart.payment_collection?.payment_sessions?.find(
370
- (paymentSession: any) => paymentSession.status === "pending",
371
- )
372
-
373
- const [isLoading, setIsLoading] = useState(false)
374
- const [error, setError] = useState<string | null>(null)
375
- const [cardBrand, setCardBrand] = useState<string | null>(null)
376
- const [cardComplete, setCardComplete] = useState(false)
377
- const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
378
- activeSession?.provider_id ?? "",
379
- )
380
- const [paypalEnabled, setPaypalEnabled] = useState(true)
381
- const [paypalTitle, setPaypalTitle] = useState("PayPal")
382
- const [cardEnabled, setCardEnabled] = useState(true)
383
- const [cardTitle, setCardTitle] = useState("Credit or Debit Card")
384
- const [paypalLoading, setPaypalLoading] = useState(false)
385
-
386
- const searchParams = useSearchParams()
387
- const router = useRouter()
388
- const pathname = usePathname()
389
-
390
- const isOpen = searchParams.get("step") === "payment"
391
-
392
- const filteredPaymentMethods = useMemo(
393
- () =>
394
- availablePaymentMethods.filter((paymentMethod) => {
395
- if (paymentMethod.id === PAYPAL_PROVIDER_ID) return paypalEnabled
396
- if (paymentMethod.id === PAYPAL_CARD_PROVIDER_ID) return cardEnabled
397
- return true
398
- }),
399
- [availablePaymentMethods, cardEnabled, paypalEnabled],
400
- )
401
-
402
- const setPaymentMethod = async (method: string) => {
403
- setError(null)
404
- setSelectedPaymentMethod(method)
405
-
406
- if (!isStripeLike(method) && !isPayPal(method)) return
407
-
408
- if (isPayPal(method)) setPaypalLoading(true)
409
-
410
- try {
411
- await initiatePaymentSession(cart, { provider_id: method })
412
- } finally {
413
- if (isPayPal(method)) setPaypalLoading(false)
414
- }
415
- }
416
-
417
- const paidByGiftcard =
418
- cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
419
-
420
- const paymentReady =
421
- (activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
422
-
423
- const createQueryString = useCallback(
424
- (name: string, value: string) => {
425
- const params = new URLSearchParams(searchParams)
426
- params.set(name, value)
427
- return params.toString()
428
- },
429
- [searchParams],
430
- )
431
-
432
- const handleEdit = () => {
433
- router.push(pathname + "?" + createQueryString("step", "payment"), {
434
- scroll: false,
435
- })
436
- }
437
-
438
- const handleSubmit = async () => {
439
- setIsLoading(true)
440
-
441
- try {
442
- const shouldInputCard =
443
- isStripeLike(selectedPaymentMethod) && !activeSession
444
- const checkActiveSession =
445
- activeSession?.provider_id === selectedPaymentMethod
446
-
447
- if (!checkActiveSession) {
448
- await initiatePaymentSession(cart, {
449
- provider_id: selectedPaymentMethod,
450
- })
451
- }
452
-
453
- if (!shouldInputCard) {
454
- return router.push(
455
- pathname + "?" + createQueryString("step", "review"),
456
- { scroll: false },
457
- )
458
- }
459
- } catch (err: any) {
460
- setError(err.message)
461
- } finally {
462
- setIsLoading(false)
463
- }
464
- }
465
-
466
- useEffect(() => {
467
- setError(null)
468
- }, [isOpen])
469
-
470
- useEffect(() => {
471
- if (!isOpen) return
472
-
473
- const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
474
- if (!backendUrl) return
475
-
476
- const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
477
- const controller = new AbortController()
478
-
479
- const loadPayPalConfig = async () => {
480
- try {
481
- const response = await fetch(`${backendUrl}/store/paypal/config`, {
482
- headers: key ? { "x-publishable-api-key": key } : {},
483
- signal: controller.signal,
484
- })
485
-
486
- if (response.status === 403) {
487
- setPaypalEnabled(false)
488
- setCardEnabled(false)
489
- return
490
- }
491
-
492
- if (!response.ok) return
493
-
494
- const config = await response.json()
495
-
496
- if (typeof config?.paypal_enabled === "boolean") setPaypalEnabled(config.paypal_enabled)
497
- if (typeof config?.paypal_title === "string" && config.paypal_title) setPaypalTitle(config.paypal_title)
498
- if (typeof config?.card_enabled === "boolean") setCardEnabled(config.card_enabled)
499
- if (typeof config?.card_title === "string" && config.card_title) setCardTitle(config.card_title)
500
- } catch (err) {
501
- if ((err as Error).name !== "AbortError") setPaypalLoading(false)
502
- }
503
- }
504
-
505
- void loadPayPalConfig()
506
- return () => controller.abort()
507
- }, [isOpen])
508
-
509
- return (
510
- <div className="bg-white">
511
- <div className="flex flex-row items-center justify-between mb-6">
512
- <Heading
513
- level="h2"
514
- className={clx(
515
- "flex flex-row text-3xl-regular gap-x-2 items-baseline",
516
- {
517
- "opacity-50 pointer-events-none select-none":
518
- !isOpen && !paymentReady,
519
- },
520
- )}
521
- >
522
- Payment
523
- {!isOpen && paymentReady && <CheckCircleSolid />}
524
- </Heading>
525
- {!isOpen && paymentReady && (
526
- <Text>
527
- <button
528
- onClick={handleEdit}
529
- className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
530
- data-testid="edit-payment-button"
531
- >
532
- Edit
533
- </button>
534
- </Text>
535
- )}
536
- </div>
537
-
538
- <div>
539
- <div className={isOpen ? "block" : "hidden"}>
540
- {!paidByGiftcard &&
541
- filteredPaymentMethods.length > 0 &&
542
- (paypalEnabled ||
543
- cardEnabled ||
544
- availablePaymentMethods.some((method) => !isPayPal(method.id))) && (
545
- <>
546
- <RadioGroup
547
- value={selectedPaymentMethod}
548
- onChange={(value: string) => setPaymentMethod(value)}
549
- >
550
- {filteredPaymentMethods.map((paymentMethod) => (
551
- <div key={paymentMethod.id}>
552
- {isStripeLike(paymentMethod.id) ? (
553
- <StripeCardContainer
554
- paymentProviderId={paymentMethod.id}
555
- selectedPaymentOptionId={selectedPaymentMethod}
556
- paymentInfoMap={paymentInfoMap}
557
- setCardBrand={setCardBrand}
558
- setError={setError}
559
- setCardComplete={setCardComplete}
560
- />
561
- ) : (
562
- <PaymentContainer
563
- paymentInfoMap={{
564
- ...paymentInfoMap,
565
- ...(paymentMethod.id === PAYPAL_PROVIDER_ID
566
- ? {
567
- [paymentMethod.id]: {
568
- ...(paymentInfoMap[paymentMethod.id] || {}),
569
- title: paypalTitle,
570
- },
571
- }
572
- : {}),
573
- ...(paymentMethod.id === PAYPAL_CARD_PROVIDER_ID
574
- ? {
575
- [paymentMethod.id]: {
576
- ...(paymentInfoMap[paymentMethod.id] || {}),
577
- title: cardTitle,
578
- },
579
- }
580
- : {}),
581
- }}
582
- paymentProviderId={paymentMethod.id}
583
- selectedPaymentOptionId={selectedPaymentMethod}
584
- />
585
- )}
586
- </div>
587
- ))}
588
- </RadioGroup>
589
-
590
- {isPayPal(selectedPaymentMethod) && paypalLoading && (
591
- <div
592
- style={{
593
- display: "flex",
594
- alignItems: "center",
595
- gap: 12,
596
- padding: "14px 16px",
597
- marginTop: 8,
598
- background: "#f9fafb",
599
- border: "1px solid #e5e7eb",
600
- borderRadius: 10,
601
- }}
602
- >
603
- <style>{`@keyframes _idx_spin{to{transform:rotate(360deg)}}`}</style>
604
- <div
605
- style={{
606
- width: 20,
607
- height: 20,
608
- borderRadius: "50%",
609
- border: "2.5px solid #e5e7eb",
610
- borderTopColor: "#0070ba",
611
- animation: "_idx_spin .7s linear infinite",
612
- flexShrink: 0,
613
- }}
614
- />
615
- <div
616
- style={{
617
- fontSize: 13,
618
- fontWeight: 500,
619
- color: "#111827",
620
- }}
621
- >
622
- Setting up payment...
623
- </div>
624
- </div>
625
- )}
626
-
627
- {isPayPal(selectedPaymentMethod) && !paypalLoading && (
628
- <MedusaNextPayPalAdapter
629
- cartId={cart.id}
630
- selectedProviderId={selectedPaymentMethod}
631
- baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
632
- publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
633
- onSuccess={async () => {
634
- await placeOrder(cart.id)
635
- }}
636
- onError={(message) => setError(message)}
637
- />
638
- )}
639
- </>
640
- )}
641
-
642
- {paidByGiftcard && (
643
- <div className="flex flex-col w-1/3">
644
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
645
- Payment method
646
- </Text>
647
- <Text
648
- className="txt-medium text-ui-fg-subtle"
649
- data-testid="payment-method-summary"
650
- >
651
- Gift card
652
- </Text>
653
- </div>
654
- )}
655
-
656
- <ErrorMessage
657
- error={error}
658
- data-testid="payment-method-error-message"
659
- />
660
-
661
- <Button
662
- size="large"
663
- className="mt-6"
664
- onClick={handleSubmit}
665
- isLoading={isLoading}
666
- disabled={
667
- (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
668
- (!selectedPaymentMethod && !paidByGiftcard) ||
669
- isPayPal(selectedPaymentMethod)
670
- }
671
- data-testid="submit-payment-button"
672
- >
673
- {!activeSession && isStripeLike(selectedPaymentMethod)
674
- ? "Enter card details"
675
- : "Continue to review"}
676
- </Button>
677
- </div>
678
-
679
- <div className={isOpen ? "hidden" : "block"}>
680
- {cart && paymentReady && activeSession ? (
681
- <div className="flex items-start gap-x-1 w-full">
682
- <div className="flex flex-col w-1/3">
683
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
684
- Payment method
685
- </Text>
686
- <Text
687
- className="txt-medium text-ui-fg-subtle"
688
- data-testid="payment-method-summary"
689
- >
690
- {activeSession?.provider_id === "pp_paypal_paypal"
691
- ? paypalTitle
692
- : activeSession?.provider_id === "pp_paypal_card_paypal_card"
693
- ? cardTitle
694
- : paymentInfoMap[activeSession?.provider_id]?.title ||
695
- activeSession?.provider_id}
696
- </Text>
697
- </div>
698
- <div className="flex flex-col w-1/3">
699
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
700
- Payment details
701
- </Text>
702
- <div
703
- className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
704
- data-testid="payment-details-summary"
705
- >
706
- <Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
707
- {paymentInfoMap[selectedPaymentMethod]?.icon || <CreditCard />}
708
- </Container>
709
- <Text>
710
- {isStripeLike(selectedPaymentMethod) && cardBrand
711
- ? cardBrand
712
- : "Another step will appear"}
713
- </Text>
714
- </div>
715
- </div>
716
- </div>
717
- ) : paidByGiftcard ? (
718
- <div className="flex flex-col w-1/3">
719
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
720
- Payment method
721
- </Text>
722
- <Text
723
- className="txt-medium text-ui-fg-subtle"
724
- data-testid="payment-method-summary"
725
- >
726
- Gift card
727
- </Text>
728
- </div>
729
- ) : null}
730
- </div>
731
- </div>
732
- <Divider className="mt-8" />
733
- </div>
734
- )
735
- }
736
-
737
- export default Payment
738
- ```
739
-
740
- ---
741
-
742
- ## ๐Ÿงช Testing
743
-
744
- Toggle between sandbox and live in **Medusa Admin โ†’ Settings โ†’ PayPal โ†’ PayPal Connection โ†’ Environment**.
745
-
746
- **Sandbox buyer account** โ€” log in at [developer.paypal.com](https://developer.paypal.com) โ†’ **Testing โ†’ Sandbox Accounts** to find your auto-generated buyer credentials. Sandbox payments do not charge real money.
747
-
748
- **Test card for Advanced Card Fields:**
749
-
750
- ```
751
- Card number 4111 1111 1111 1111
752
- Expiry Any future date
753
- CVV Any 3 digits
754
- ```
755
-
756
- ---
757
-
758
- ## ๐Ÿ“„ License
759
-
1
+ # PayPal for Medusa Frontend UI
2
+
3
+ **PayPal checkout UI for Medusa v2 storefronts โ€” Smart Buttons, Advanced Card Fields**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@easypayment/medusa-paypal-ui?color=blue&label=npm)](https://www.npmjs.com/package/@easypayment/medusa-paypal-ui)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
7
+ [![Medusa v2](https://img.shields.io/badge/Medusa-v2-9b59b6)](https://medusajs.com)
8
+ [![Next.js](https://img.shields.io/badge/Next.js-14%2B-black)](https://nextjs.org)
9
+
10
+ ---
11
+
12
+ ## ๐Ÿ“‹ Table of Contents
13
+
14
+ - [๐Ÿ“ฆ Overview](#-overview)
15
+ - [โœ… Requirements](#-requirements)
16
+ - [๐Ÿš€ Installation](#-installation)
17
+ - [๐Ÿ”‘ Environment Variables](#-environment-variables)
18
+ - [๐Ÿ”— Integration Guide](#-integration-guide)
19
+ - [Step 1 โ€” Add the import](#step-1--add-the-import)
20
+ - [Step 2 โ€” Add PayPal helpers and state](#step-2--add-paypal-helpers-and-state)
21
+ - [Step 3 โ€” Load PayPal config](#step-3--load-paypal-config)
22
+ - [Step 4 โ€” Update setPaymentMethod](#step-4--update-setpaymentmethod)
23
+ - [Step 5 โ€” Filter the payment method list](#step-5--filter-the-payment-method-list)
24
+ - [Step 6 โ€” Inject admin-configured titles](#step-6--inject-admin-configured-titles)
25
+ - [Step 7 โ€” Render the PayPal UI](#step-7--render-the-paypal-ui)
26
+ - [Step 8 โ€” Disable the Continue button](#step-8--disable-the-continue-button)
27
+ - [Step 9 โ€” Fix the summary label](#step-9--fix-the-summary-label)
28
+ - [๐Ÿ“„ Complete File](#-complete-file)
29
+ - [๐Ÿ›ก Built-in payment protection](#-built-in-payment-protection)
30
+ - [๐Ÿงช Testing](#-testing)
31
+ - [๐Ÿ“„ License](#-license)
32
+
33
+ ---
34
+
35
+ ## ๐Ÿ“ฆ Overview
36
+
37
+ `@easypayment/medusa-paypal-ui` is the **storefront UI package** that connects your Next.js (App Router) storefront to the `@easypayment/medusa-paypal` backend plugin. It ships the PayPal adapter used inside your checkout payment step โ€” your storefront adds the adapter, provider filtering, and backend config handling to the existing Medusa payment UI.
38
+
39
+ | Feature | Details |
40
+ |---|---|
41
+ | ๐Ÿ”ต **PayPal Smart Buttons** | Wallet-based checkout via `pp_paypal_paypal` |
42
+ | ๐Ÿ’ณ **Advanced Card Fields** | Hosted PCI-compliant advanced credit card inputs via `pp_paypal_card_paypal_card` |
43
+ | ๐Ÿ›  **Admin-driven config** | Enable/disable providers and set labels from Medusa Admin |
44
+ | โšก **Built-in UX** | Smart Buttons and Advanced Card UI rendered by `MedusaNextPayPalAdapter` |
45
+ | ๐Ÿ”„ **Storefront-controlled flow** | Your payment step controls session creation, loading states, and `placeOrder` |
46
+
47
+ ---
48
+
49
+ ## โœ… Requirements
50
+
51
+ - **Node.js** 18+
52
+ - **Next.js** 14+ with App Router
53
+ - **`@easypayment/medusa-paypal`** installed and running on your Medusa server
54
+ - A PayPal account connected in **Medusa Admin โ†’ Settings โ†’ PayPal โ†’ PayPal Connection**
55
+
56
+ ---
57
+
58
+ ## ๐Ÿš€ Installation
59
+
60
+ **In your storefront directory**, run:
61
+
62
+ ```bash
63
+ npm install @easypayment/medusa-paypal-ui
64
+ ```
65
+
66
+ ---
67
+
68
+ ## ๐Ÿ”‘ Environment Variables
69
+
70
+ Add the following to your storefront `.env.local`. Use separate values for development and production.
71
+
72
+ ```env
73
+ NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
74
+ NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_...
75
+ ```
76
+
77
+ > **Where to get the publishable key:**
78
+ > Medusa Admin โ†’ **Settings โ†’ API Key Management โ†’ Create API Key**
79
+
80
+ ---
81
+
82
+ ## ๐Ÿ”— Integration Guide
83
+
84
+ All changes in this guide are made to **one single file** in your storefront:
85
+
86
+ ```
87
+ src/modules/checkout/components/payment/index.tsx
88
+ ```
89
+
90
+ Open that file and follow each step in order.
91
+
92
+ > **Prefer to copy-paste the whole file?** Skip straight to [Complete File](#-complete-file) and replace the entire contents in one go. The complete file has all 9 steps already applied.
93
+
94
+ ---
95
+
96
+ ### Step 1 โ€” Add the import
97
+
98
+ **Where:** At the very top of the file, alongside your other imports.
99
+
100
+ ```tsx
101
+ import { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"
102
+ ```
103
+
104
+ ---
105
+
106
+ ### Step 2 โ€” Add PayPal helpers and state
107
+
108
+ **Where:** At the top of the file, outside the component โ€” add the constants. Inside the `Payment` component, add the `useState` lines alongside your other state declarations.
109
+
110
+ ```tsx
111
+ // Outside the component โ€” add these constants
112
+ const PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
113
+ const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
114
+ const PAYPAL_PROVIDER_IDS = [PAYPAL_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID]
115
+
116
+ const isPayPal = (id: string) => PAYPAL_PROVIDER_IDS.includes(id)
117
+ ```
118
+
119
+ ```tsx
120
+ // Inside the Payment component โ€” add alongside your other useState declarations
121
+ const [paypalEnabled, setPaypalEnabled] = useState(true)
122
+ const [paypalTitle, setPaypalTitle] = useState("PayPal")
123
+ const [cardEnabled, setCardEnabled] = useState(true)
124
+ const [cardTitle, setCardTitle] = useState("Credit or Debit Card")
125
+ const [paypalLoading, setPaypalLoading] = useState(false)
126
+ ```
127
+
128
+ ---
129
+
130
+ ### Step 3 โ€” Load PayPal config
131
+
132
+ **Where:** Inside the `Payment` component, alongside your other `useEffect` hooks.
133
+
134
+ This fetches PayPal settings from your backend whenever the payment step is opened, so the UI always reflects the latest admin configuration.
135
+
136
+ ```tsx
137
+ useEffect(() => {
138
+ if (!isOpen) return
139
+
140
+ const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
141
+ if (!backendUrl) return
142
+
143
+ const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
144
+ const controller = new AbortController()
145
+
146
+ const loadPayPalConfig = async () => {
147
+ try {
148
+ const response = await fetch(`${backendUrl}/store/paypal/config`, {
149
+ headers: key ? { "x-publishable-api-key": key } : {},
150
+ signal: controller.signal,
151
+ })
152
+
153
+ if (response.status === 403) {
154
+ setPaypalEnabled(false)
155
+ setCardEnabled(false)
156
+ return
157
+ }
158
+
159
+ if (!response.ok) return
160
+
161
+ const config = await response.json()
162
+
163
+ if (typeof config?.paypal_enabled === "boolean") setPaypalEnabled(config.paypal_enabled)
164
+ if (typeof config?.paypal_title === "string" && config.paypal_title) setPaypalTitle(config.paypal_title)
165
+ if (typeof config?.card_enabled === "boolean") setCardEnabled(config.card_enabled)
166
+ if (typeof config?.card_title === "string" && config.card_title) setCardTitle(config.card_title)
167
+ } catch (err) {
168
+ if ((err as Error).name !== "AbortError") setPaypalLoading(false)
169
+ }
170
+ }
171
+
172
+ void loadPayPalConfig()
173
+ return () => controller.abort()
174
+ }, [isOpen])
175
+ ```
176
+
177
+ ---
178
+
179
+ ### Step 4 โ€” Update setPaymentMethod
180
+
181
+ **Where:** Inside the `Payment` component. Find your existing `setPaymentMethod` function and **replace it entirely** with the version below.
182
+
183
+ The key addition is `paypalLoading` โ€” it shows a loading indicator while the PayPal payment session is being created in the background.
184
+
185
+ ```tsx
186
+ const setPaymentMethod = async (method: string) => {
187
+ setError(null)
188
+ setSelectedPaymentMethod(method)
189
+
190
+ if (!isStripeLike(method) && !isPayPal(method)) return
191
+
192
+ if (isPayPal(method)) setPaypalLoading(true)
193
+
194
+ try {
195
+ await initiatePaymentSession(cart, { provider_id: method })
196
+ } finally {
197
+ if (isPayPal(method)) setPaypalLoading(false)
198
+ }
199
+ }
200
+ ```
201
+
202
+ ---
203
+
204
+ ### Step 5 โ€” Filter the payment method list
205
+
206
+ **Where:** Inside the `Payment` component, alongside your other `useMemo` declarations โ€” add this before the `return` statement.
207
+
208
+ This hides PayPal or Card from the list if they have been disabled in Medusa Admin.
209
+
210
+ ```tsx
211
+ const filteredPaymentMethods = useMemo(
212
+ () =>
213
+ availablePaymentMethods.filter((paymentMethod) => {
214
+ if (paymentMethod.id === PAYPAL_PROVIDER_ID) return paypalEnabled
215
+ if (paymentMethod.id === PAYPAL_CARD_PROVIDER_ID) return cardEnabled
216
+ return true
217
+ }),
218
+ [availablePaymentMethods, cardEnabled, paypalEnabled],
219
+ )
220
+ ```
221
+
222
+ Then in your JSX, find where you render `availablePaymentMethods.map(...)` and **replace** `availablePaymentMethods` with `filteredPaymentMethods`:
223
+
224
+ ```tsx
225
+ // Before
226
+ availablePaymentMethods.map((paymentMethod) => ( ... ))
227
+
228
+ // After
229
+ filteredPaymentMethods.map((paymentMethod) => ( ... ))
230
+ ```
231
+
232
+ ---
233
+
234
+ ### Step 6 โ€” Inject admin-configured titles
235
+
236
+ **Where:** Inside the `.map()` loop from Step 5, find your `<PaymentContainer>` component and **replace** its `paymentInfoMap` prop with the version below.
237
+
238
+ This makes the radio button labels show the titles configured in Medusa Admin instead of hardcoded defaults.
239
+
240
+ ```tsx
241
+ <PaymentContainer
242
+ paymentInfoMap={{
243
+ ...paymentInfoMap,
244
+ ...(paymentMethod.id === PAYPAL_PROVIDER_ID
245
+ ? { [paymentMethod.id]: { ...(paymentInfoMap[paymentMethod.id] || {}), title: paypalTitle } }
246
+ : {}),
247
+ ...(paymentMethod.id === PAYPAL_CARD_PROVIDER_ID
248
+ ? { [paymentMethod.id]: { ...(paymentInfoMap[paymentMethod.id] || {}), title: cardTitle } }
249
+ : {}),
250
+ }}
251
+ paymentProviderId={paymentMethod.id}
252
+ selectedPaymentOptionId={selectedPaymentMethod}
253
+ />
254
+ ```
255
+
256
+ ---
257
+
258
+ ### Step 7 โ€” Render the PayPal UI
259
+
260
+ **Where:** In the JSX, immediately after the closing `</RadioGroup>` tag.
261
+
262
+ The first block shows a loading spinner while the session is being set up. The second block renders the PayPal buttons or card fields once the session is ready.
263
+
264
+ ```tsx
265
+ {/* Loading state while PayPal session is being created */}
266
+ {isPayPal(selectedPaymentMethod) && paypalLoading && (
267
+ <div>Setting up payment...</div>
268
+ )}
269
+
270
+ {/* PayPal buttons or card fields */}
271
+ {isPayPal(selectedPaymentMethod) && !paypalLoading && (
272
+ <MedusaNextPayPalAdapter
273
+ cartId={cart.id}
274
+ selectedProviderId={selectedPaymentMethod}
275
+ baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
276
+ publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
277
+ onSuccess={async () => {
278
+ await placeOrder(cart.id)
279
+ }}
280
+ onError={(message) => setError(message)}
281
+ />
282
+ )}
283
+ ```
284
+
285
+ ---
286
+
287
+ ### Step 8 โ€” Disable the Continue button
288
+
289
+ **Where:** In the JSX, find your existing `<Button>` with `data-testid="submit-payment-button"` and **add** `isPayPal(selectedPaymentMethod)` to its `disabled` prop.
290
+
291
+ PayPal handles its own checkout action, so the "Continue to review" button must be hidden from the flow when PayPal is selected.
292
+
293
+ ```tsx
294
+ <Button
295
+ size="large"
296
+ className="mt-6"
297
+ onClick={handleSubmit}
298
+ isLoading={isLoading}
299
+ disabled={
300
+ (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
301
+ (!selectedPaymentMethod && !paidByGiftcard) ||
302
+ isPayPal(selectedPaymentMethod) // ๐Ÿ‘ˆ add this line
303
+ }
304
+ data-testid="submit-payment-button"
305
+ >
306
+ {!activeSession && isStripeLike(selectedPaymentMethod)
307
+ ? "Enter card details"
308
+ : "Continue to review"}
309
+ </Button>
310
+ ```
311
+
312
+ ---
313
+
314
+ ### Step 9 โ€” Fix the summary label
315
+
316
+ **Where:** In the collapsed summary view (shown after the customer has completed the payment step). Find the `<Text>` with `data-testid="payment-method-summary"` and **replace its content** with the version below.
317
+
318
+ This shows the admin-configured title instead of a hardcoded or missing label.
319
+
320
+ ```tsx
321
+ <Text
322
+ className="txt-medium text-ui-fg-subtle"
323
+ data-testid="payment-method-summary"
324
+ >
325
+ {activeSession?.provider_id === "pp_paypal_paypal"
326
+ ? paypalTitle
327
+ : activeSession?.provider_id === "pp_paypal_card_paypal_card"
328
+ ? cardTitle
329
+ : paymentInfoMap[activeSession?.provider_id]?.title ||
330
+ activeSession?.provider_id}
331
+ </Text>
332
+ ```
333
+
334
+ ---
335
+
336
+ ## ๐Ÿ“„ Complete File
337
+
338
+ If you prefer to copy-paste the entire file at once, replace the full contents of `src/modules/checkout/components/payment/index.tsx` with the following:
339
+
340
+ ```tsx
341
+ "use client"
342
+
343
+ import { RadioGroup } from "@headlessui/react"
344
+ import { initiatePaymentSession, placeOrder } from "@lib/data/cart"
345
+ import { isStripeLike, paymentInfoMap } from "@lib/constants"
346
+ import { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"
347
+ import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
348
+ import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
349
+ import ErrorMessage from "@modules/checkout/components/error-message"
350
+ import PaymentContainer, {
351
+ StripeCardContainer,
352
+ } from "@modules/checkout/components/payment-container"
353
+ import Divider from "@modules/common/components/divider"
354
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
355
+ import { useCallback, useEffect, useMemo, useState } from "react"
356
+
357
+ const PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
358
+ const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
359
+ const PAYPAL_PROVIDER_IDS = [PAYPAL_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID]
360
+
361
+ const isPayPal = (id: string) => PAYPAL_PROVIDER_IDS.includes(id)
362
+
363
+ const Payment = ({
364
+ cart,
365
+ availablePaymentMethods,
366
+ }: {
367
+ cart: any
368
+ availablePaymentMethods: any[]
369
+ }) => {
370
+ const activeSession = cart.payment_collection?.payment_sessions?.find(
371
+ (paymentSession: any) => paymentSession.status === "pending",
372
+ )
373
+
374
+ const [isLoading, setIsLoading] = useState(false)
375
+ const [error, setError] = useState<string | null>(null)
376
+ const [cardBrand, setCardBrand] = useState<string | null>(null)
377
+ const [cardComplete, setCardComplete] = useState(false)
378
+ const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
379
+ activeSession?.provider_id ?? "",
380
+ )
381
+ const [paypalEnabled, setPaypalEnabled] = useState(true)
382
+ const [paypalTitle, setPaypalTitle] = useState("PayPal")
383
+ const [cardEnabled, setCardEnabled] = useState(true)
384
+ const [cardTitle, setCardTitle] = useState("Credit or Debit Card")
385
+ const [paypalLoading, setPaypalLoading] = useState(false)
386
+
387
+ const searchParams = useSearchParams()
388
+ const router = useRouter()
389
+ const pathname = usePathname()
390
+
391
+ const isOpen = searchParams.get("step") === "payment"
392
+
393
+ const filteredPaymentMethods = useMemo(
394
+ () =>
395
+ availablePaymentMethods.filter((paymentMethod) => {
396
+ if (paymentMethod.id === PAYPAL_PROVIDER_ID) return paypalEnabled
397
+ if (paymentMethod.id === PAYPAL_CARD_PROVIDER_ID) return cardEnabled
398
+ return true
399
+ }),
400
+ [availablePaymentMethods, cardEnabled, paypalEnabled],
401
+ )
402
+
403
+ const setPaymentMethod = async (method: string) => {
404
+ setError(null)
405
+ setSelectedPaymentMethod(method)
406
+
407
+ if (!isStripeLike(method) && !isPayPal(method)) return
408
+
409
+ if (isPayPal(method)) setPaypalLoading(true)
410
+
411
+ try {
412
+ await initiatePaymentSession(cart, { provider_id: method })
413
+ } finally {
414
+ if (isPayPal(method)) setPaypalLoading(false)
415
+ }
416
+ }
417
+
418
+ const paidByGiftcard =
419
+ cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
420
+
421
+ const paymentReady =
422
+ (activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
423
+
424
+ const createQueryString = useCallback(
425
+ (name: string, value: string) => {
426
+ const params = new URLSearchParams(searchParams)
427
+ params.set(name, value)
428
+ return params.toString()
429
+ },
430
+ [searchParams],
431
+ )
432
+
433
+ const handleEdit = () => {
434
+ router.push(pathname + "?" + createQueryString("step", "payment"), {
435
+ scroll: false,
436
+ })
437
+ }
438
+
439
+ const handleSubmit = async () => {
440
+ setIsLoading(true)
441
+
442
+ try {
443
+ const shouldInputCard =
444
+ isStripeLike(selectedPaymentMethod) && !activeSession
445
+ const checkActiveSession =
446
+ activeSession?.provider_id === selectedPaymentMethod
447
+
448
+ if (!checkActiveSession) {
449
+ await initiatePaymentSession(cart, {
450
+ provider_id: selectedPaymentMethod,
451
+ })
452
+ }
453
+
454
+ if (!shouldInputCard) {
455
+ return router.push(
456
+ pathname + "?" + createQueryString("step", "review"),
457
+ { scroll: false },
458
+ )
459
+ }
460
+ } catch (err: any) {
461
+ setError(err.message)
462
+ } finally {
463
+ setIsLoading(false)
464
+ }
465
+ }
466
+
467
+ useEffect(() => {
468
+ setError(null)
469
+ }, [isOpen])
470
+
471
+ useEffect(() => {
472
+ if (!isOpen) return
473
+
474
+ const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
475
+ if (!backendUrl) return
476
+
477
+ const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
478
+ const controller = new AbortController()
479
+
480
+ const loadPayPalConfig = async () => {
481
+ try {
482
+ const response = await fetch(`${backendUrl}/store/paypal/config`, {
483
+ headers: key ? { "x-publishable-api-key": key } : {},
484
+ signal: controller.signal,
485
+ })
486
+
487
+ if (response.status === 403) {
488
+ setPaypalEnabled(false)
489
+ setCardEnabled(false)
490
+ return
491
+ }
492
+
493
+ if (!response.ok) return
494
+
495
+ const config = await response.json()
496
+
497
+ if (typeof config?.paypal_enabled === "boolean") setPaypalEnabled(config.paypal_enabled)
498
+ if (typeof config?.paypal_title === "string" && config.paypal_title) setPaypalTitle(config.paypal_title)
499
+ if (typeof config?.card_enabled === "boolean") setCardEnabled(config.card_enabled)
500
+ if (typeof config?.card_title === "string" && config.card_title) setCardTitle(config.card_title)
501
+ } catch (err) {
502
+ if ((err as Error).name !== "AbortError") setPaypalLoading(false)
503
+ }
504
+ }
505
+
506
+ void loadPayPalConfig()
507
+ return () => controller.abort()
508
+ }, [isOpen])
509
+
510
+ return (
511
+ <div className="bg-white">
512
+ <div className="flex flex-row items-center justify-between mb-6">
513
+ <Heading
514
+ level="h2"
515
+ className={clx(
516
+ "flex flex-row text-3xl-regular gap-x-2 items-baseline",
517
+ {
518
+ "opacity-50 pointer-events-none select-none":
519
+ !isOpen && !paymentReady,
520
+ },
521
+ )}
522
+ >
523
+ Payment
524
+ {!isOpen && paymentReady && <CheckCircleSolid />}
525
+ </Heading>
526
+ {!isOpen && paymentReady && (
527
+ <Text>
528
+ <button
529
+ onClick={handleEdit}
530
+ className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
531
+ data-testid="edit-payment-button"
532
+ >
533
+ Edit
534
+ </button>
535
+ </Text>
536
+ )}
537
+ </div>
538
+
539
+ <div>
540
+ <div className={isOpen ? "block" : "hidden"}>
541
+ {!paidByGiftcard &&
542
+ filteredPaymentMethods.length > 0 &&
543
+ (paypalEnabled ||
544
+ cardEnabled ||
545
+ availablePaymentMethods.some((method) => !isPayPal(method.id))) && (
546
+ <>
547
+ <RadioGroup
548
+ value={selectedPaymentMethod}
549
+ onChange={(value: string) => setPaymentMethod(value)}
550
+ >
551
+ {filteredPaymentMethods.map((paymentMethod) => (
552
+ <div key={paymentMethod.id}>
553
+ {isStripeLike(paymentMethod.id) ? (
554
+ <StripeCardContainer
555
+ paymentProviderId={paymentMethod.id}
556
+ selectedPaymentOptionId={selectedPaymentMethod}
557
+ paymentInfoMap={paymentInfoMap}
558
+ setCardBrand={setCardBrand}
559
+ setError={setError}
560
+ setCardComplete={setCardComplete}
561
+ />
562
+ ) : (
563
+ <PaymentContainer
564
+ paymentInfoMap={{
565
+ ...paymentInfoMap,
566
+ ...(paymentMethod.id === PAYPAL_PROVIDER_ID
567
+ ? {
568
+ [paymentMethod.id]: {
569
+ ...(paymentInfoMap[paymentMethod.id] || {}),
570
+ title: paypalTitle,
571
+ },
572
+ }
573
+ : {}),
574
+ ...(paymentMethod.id === PAYPAL_CARD_PROVIDER_ID
575
+ ? {
576
+ [paymentMethod.id]: {
577
+ ...(paymentInfoMap[paymentMethod.id] || {}),
578
+ title: cardTitle,
579
+ },
580
+ }
581
+ : {}),
582
+ }}
583
+ paymentProviderId={paymentMethod.id}
584
+ selectedPaymentOptionId={selectedPaymentMethod}
585
+ />
586
+ )}
587
+ </div>
588
+ ))}
589
+ </RadioGroup>
590
+
591
+ {isPayPal(selectedPaymentMethod) && paypalLoading && (
592
+ <div
593
+ style={{
594
+ display: "flex",
595
+ alignItems: "center",
596
+ gap: 12,
597
+ padding: "14px 16px",
598
+ marginTop: 8,
599
+ background: "#f9fafb",
600
+ border: "1px solid #e5e7eb",
601
+ borderRadius: 10,
602
+ }}
603
+ >
604
+ <style>{`@keyframes _idx_spin{to{transform:rotate(360deg)}}`}</style>
605
+ <div
606
+ style={{
607
+ width: 20,
608
+ height: 20,
609
+ borderRadius: "50%",
610
+ border: "2.5px solid #e5e7eb",
611
+ borderTopColor: "#0070ba",
612
+ animation: "_idx_spin .7s linear infinite",
613
+ flexShrink: 0,
614
+ }}
615
+ />
616
+ <div
617
+ style={{
618
+ fontSize: 13,
619
+ fontWeight: 500,
620
+ color: "#111827",
621
+ }}
622
+ >
623
+ Setting up payment...
624
+ </div>
625
+ </div>
626
+ )}
627
+
628
+ {isPayPal(selectedPaymentMethod) && !paypalLoading && (
629
+ <MedusaNextPayPalAdapter
630
+ cartId={cart.id}
631
+ selectedProviderId={selectedPaymentMethod}
632
+ baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
633
+ publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
634
+ onSuccess={async () => {
635
+ await placeOrder(cart.id)
636
+ }}
637
+ onError={(message) => setError(message)}
638
+ />
639
+ )}
640
+ </>
641
+ )}
642
+
643
+ {paidByGiftcard && (
644
+ <div className="flex flex-col w-1/3">
645
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
646
+ Payment method
647
+ </Text>
648
+ <Text
649
+ className="txt-medium text-ui-fg-subtle"
650
+ data-testid="payment-method-summary"
651
+ >
652
+ Gift card
653
+ </Text>
654
+ </div>
655
+ )}
656
+
657
+ <ErrorMessage
658
+ error={error}
659
+ data-testid="payment-method-error-message"
660
+ />
661
+
662
+ <Button
663
+ size="large"
664
+ className="mt-6"
665
+ onClick={handleSubmit}
666
+ isLoading={isLoading}
667
+ disabled={
668
+ (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
669
+ (!selectedPaymentMethod && !paidByGiftcard) ||
670
+ isPayPal(selectedPaymentMethod)
671
+ }
672
+ data-testid="submit-payment-button"
673
+ >
674
+ {!activeSession && isStripeLike(selectedPaymentMethod)
675
+ ? "Enter card details"
676
+ : "Continue to review"}
677
+ </Button>
678
+ </div>
679
+
680
+ <div className={isOpen ? "hidden" : "block"}>
681
+ {cart && paymentReady && activeSession ? (
682
+ <div className="flex items-start gap-x-1 w-full">
683
+ <div className="flex flex-col w-1/3">
684
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
685
+ Payment method
686
+ </Text>
687
+ <Text
688
+ className="txt-medium text-ui-fg-subtle"
689
+ data-testid="payment-method-summary"
690
+ >
691
+ {activeSession?.provider_id === "pp_paypal_paypal"
692
+ ? paypalTitle
693
+ : activeSession?.provider_id === "pp_paypal_card_paypal_card"
694
+ ? cardTitle
695
+ : paymentInfoMap[activeSession?.provider_id]?.title ||
696
+ activeSession?.provider_id}
697
+ </Text>
698
+ </div>
699
+ <div className="flex flex-col w-1/3">
700
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
701
+ Payment details
702
+ </Text>
703
+ <div
704
+ className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
705
+ data-testid="payment-details-summary"
706
+ >
707
+ <Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
708
+ {paymentInfoMap[selectedPaymentMethod]?.icon || <CreditCard />}
709
+ </Container>
710
+ <Text>
711
+ {isStripeLike(selectedPaymentMethod) && cardBrand
712
+ ? cardBrand
713
+ : "Another step will appear"}
714
+ </Text>
715
+ </div>
716
+ </div>
717
+ </div>
718
+ ) : paidByGiftcard ? (
719
+ <div className="flex flex-col w-1/3">
720
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
721
+ Payment method
722
+ </Text>
723
+ <Text
724
+ className="txt-medium text-ui-fg-subtle"
725
+ data-testid="payment-method-summary"
726
+ >
727
+ Gift card
728
+ </Text>
729
+ </div>
730
+ ) : null}
731
+ </div>
732
+ </div>
733
+ <Divider className="mt-8" />
734
+ </div>
735
+ )
736
+ }
737
+
738
+ export default Payment
739
+ ```
740
+
741
+ ---
742
+
743
+ ## ๐Ÿ›ก Built-in payment protection
744
+
745
+ You don't need to do anything for these โ€” they're automatic:
746
+
747
+ - **No double charges.** Once a customer has paid, the checkout only ever offers a *"Retry โ€” finish placing my order"* button โ€” even if the page is reloaded or the browser crashes mid-checkout.
748
+ - **Always the right amount.** If the customer changes their cart during checkout, a fresh PayPal order is created at the new total.
749
+ - **Clear messages.** Customers see friendly, actionable messages instead of technical errors, and a payment method you've disabled in Medusa Admin shows a polite "currently unavailable" note.
750
+
751
+ <details>
752
+ <summary><b>For developers</b></summary>
753
+
754
+ <br>
755
+
756
+ - `usePayPalPaymentMethods` returns an `error` field (non-null when the config fetch failed and the enabled flags are optimistic defaults).
757
+ - Captured-state helpers are exported for custom flows: `markCartCaptured` / `wasCartCaptured` / `clearCartCaptured` (per-cart `sessionStorage` flag behind the reload-safe retry).
758
+ - If your backend responds with `Access-Control-Allow-Origin: *`, pass `credentials: "omit"` to `createPayPalStoreApi({ ... })` โ€” browsers block credentialed requests against a wildcard origin. Default stays `"include"`.
759
+
760
+ </details>
761
+
762
+ ---
763
+
764
+ ## ๐Ÿงช Testing
765
+
766
+ Toggle between sandbox and live in **Medusa Admin โ†’ Settings โ†’ PayPal โ†’ PayPal Connection โ†’ Environment**.
767
+
768
+ **Sandbox buyer account** โ€” log in at [developer.paypal.com](https://developer.paypal.com) โ†’ **Testing โ†’ Sandbox Accounts** to find your auto-generated buyer credentials. Sandbox payments do not charge real money.
769
+
770
+ **Test card for Advanced Card Fields:**
771
+
772
+ ```
773
+ Card number 4111 1111 1111 1111
774
+ Expiry Any future date
775
+ CVV Any 3 digits
776
+ ```
777
+
778
+ ---
779
+
780
+ ## ๐Ÿ“„ License
781
+
760
782
  MIT ยฉ [Easy Payment](https://www.npmjs.com/package/@easypayment/medusa-paypal-ui)