@easypayment/medusa-paypal-ui 1.0.43 โ†’ 1.0.45

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,615 +1,760 @@
1
- <div align="center">
2
-
3
- <h1>๐Ÿ…ฟ medusa-paypal-frontend</h1>
4
-
5
- <p><strong>Production-ready PayPal checkout UI for Medusa v2 storefronts</strong></p>
6
-
7
- <p>
8
- <a href="https://www.npmjs.com/package/@easypayment/medusa-paypal-ui"><img src="https://img.shields.io/npm/v/@easypayment/medusa-paypal-ui?color=blue&label=npm" alt="npm version" /></a>
9
- <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green.svg" alt="License: MIT" /></a>
10
- <a href="https://medusajs.com"><img src="https://img.shields.io/badge/Medusa-v2-9b59b6" alt="Medusa v2" /></a>
11
- <a href="https://nextjs.org"><img src="https://img.shields.io/badge/Next.js-14%2B-black" alt="Next.js" /></a>
12
- </p>
13
-
14
- <p>PayPal Smart Buttons ยท Advanced Card Fields ยท Built-in loading states ยท Admin-controlled settings</p>
15
-
16
- </div>
17
-
18
- ---
19
-
20
- ## Overview
21
-
22
- `@easypayment/medusa-paypal-ui` is a React UI package that connects your Next.js (App Router) storefront to the `medusa-paypal-backend` plugin. It ships fully self-contained checkout components โ€” your storefront only needs a single component and one hook added to the existing payment step.
23
-
24
- | Feature | Details |
25
- |---|---|
26
- | **PayPal Smart Buttons** | Wallet-based checkout via `pp_paypal_paypal` |
27
- | **Advanced Card Fields** | Hosted PCI-compliant card inputs via `pp_paypal_card_paypal_card` |
28
- | **Admin-driven config** | Enable/disable providers and set labels from Medusa Admin โ€” no code changes needed |
29
- | **Built-in UX** | Loading spinners, error display, and currency warnings are all handled internally |
30
- | **5-minute config cache** | `/store/paypal/config` is fetched once per session โ€” no redundant requests |
31
-
32
- ---
33
-
34
- ## Table of Contents
35
-
36
- - [Requirements](#requirements)
37
- - [Installation](#installation)
38
- - [Environment Variables](#environment-variables)
39
- - [Integration Guide](#integration-guide)
40
- - [Step 1 โ€” Add the import](#step-1-add-the-import)
41
- - [Step 2 โ€” Add session loading state](#step-2-add-session-loading-state)
42
- - [Step 3 โ€” Add the config hook](#step-3-add-the-config-hook)
43
- - [Step 4 โ€” Update setPaymentMethod](#step-4-update-setpaymentmethod)
44
- - [Step 5 โ€” Filter the payment method list](#step-5-filter-the-payment-method-list)
45
- - [Step 6 โ€” Inject admin-configured titles](#step-6-inject-admin-configured-titles)
46
- - [Step 7 โ€” Render the PayPal UI](#step-7-render-the-paypal-ui)
47
- - [Step 8 โ€” Disable the Continue button](#step-8-disable-the-continue-button)
48
- - [Step 9 โ€” Fix the summary label](#step-9-fix-the-summary-label)
49
- - [Complete File](#complete-file)
50
- - [Testing](#testing)
51
-
52
- ---
53
-
54
- ## Requirements
55
-
56
- - **Node.js** 18+
57
- - **Next.js** 14+ with App Router
58
- - **`medusa-paypal-backend`** installed and running on your Medusa server
59
- - A PayPal account connected in Medusa Admin โ†’ Settings โ†’ PayPal โ†’ PayPal Connection
60
-
61
- ---
62
-
63
- ## Installation
64
-
65
- ```bash
66
- npm install @easypayment/medusa-paypal-ui
67
- ```
68
-
69
- ```bash
70
- yarn add @easypayment/medusa-paypal-ui
71
- ```
72
-
73
- ---
74
-
75
- ## Environment Variables
76
-
77
- Add the following to your storefront `.env.local`. Use separate values for development and production.
78
-
79
- ```env
80
- # โ”€โ”€ Development โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
81
- NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
82
- NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_...
83
-
84
- # โ”€โ”€ Production โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
85
- NEXT_PUBLIC_MEDUSA_BACKEND_URL=https://your-medusa-server.com
86
- NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_...
87
- ```
88
-
89
- > **Where to get the publishable key:**
90
- > Medusa Admin โ†’ **Settings โ†’ API Key Management โ†’ Create API Key**
91
-
92
- ---
93
-
94
- ## Integration Guide
95
-
96
- All changes are made to a single file in your storefront:
97
-
98
- ```
99
- src/modules/checkout/components/payment/index.tsx
100
- ```
101
-
102
- > **Prefer copy-paste?** Skip to [Complete File](#complete-file) for a ready-to-use drop-in replacement.
103
-
104
- ---
105
-
106
- ### Step 1 โ€” Add the import
107
-
108
- Add this import alongside your existing imports at the top of the file:
109
-
110
- ```tsx
111
- import {
112
- PayPalPaymentSection,
113
- isPayPalProviderId,
114
- usePayPalPaymentMethods,
115
- } from "@easypayment/medusa-paypal-ui"
116
- ```
117
-
118
- ---
119
-
120
- ### Step 2 โ€” Add session loading state
121
-
122
- Inside the `Payment` component, add one new state variable alongside your existing `useState` calls:
123
-
124
- ```tsx
125
- const [paypalSessionLoading, setPaypalSessionLoading] = useState(false)
126
- ```
127
-
128
- This is set to `true` while `initiatePaymentSession` is running, allowing the PayPal UI to show a "Setting up paymentโ€ฆ" spinner in place of the buttons during that window.
129
-
130
- ---
131
-
132
- ### Step 3 โ€” Add the config hook
133
-
134
- Add this hook call inside the component, directly after the `const isOpen = ...` line:
135
-
136
- ```tsx
137
- const { paypalEnabled, paypalTitle, cardEnabled, cardTitle } =
138
- usePayPalPaymentMethods({
139
- baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
140
- publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
141
- cartId: cart.id,
142
- enabled: isOpen,
143
- })
144
- ```
145
-
146
- This fetches live settings from your Medusa backend โ€” provider enabled/disabled flags and admin-configured display labels. Responses are cached for 5 minutes per cart so there are no duplicate network requests.
147
-
148
- Passing `enabled: isOpen` ensures the fetch only runs when the payment step is actually visible.
149
-
150
- ---
151
-
152
- ### Step 4 โ€” Update `setPaymentMethod`
153
-
154
- Replace your existing `setPaymentMethod` function with the following. The key addition is setting `paypalSessionLoading` while the session is being created:
155
-
156
- ```tsx
157
- const setPaymentMethod = async (method: string) => {
158
- setError(null)
159
- setSelectedPaymentMethod(method)
160
- if (isStripeLike(method) || isPayPalProviderId(method)) {
161
- if (isPayPalProviderId(method)) setPaypalSessionLoading(true)
162
- try {
163
- await initiatePaymentSession(cart, { provider_id: method })
164
- } finally {
165
- setPaypalSessionLoading(false)
166
- }
167
- }
168
- }
169
- ```
170
-
171
- > **Note:** `isStripeLike` comes from your storefront's `@lib/constants`. If your project does not use Stripe, remove the `isStripeLike(method)` condition and keep only `isPayPalProviderId(method)`.
172
-
173
- ---
174
-
175
- ### Step 5 โ€” Filter the payment method list
176
-
177
- In your `RadioGroup`, add a `.filter()` before `.map()` to hide providers that have been disabled by the admin in the PayPal settings panel:
178
-
179
- ```tsx
180
- {availablePaymentMethods
181
- .filter((m) => {
182
- if (m.id === "pp_paypal_paypal" && !paypalEnabled) return false
183
- if (m.id === "pp_paypal_card_paypal_card" && !cardEnabled) return false
184
- return true
185
- })
186
- .map((paymentMethod) => (
187
- // ... your existing rendering code, unchanged
188
- ))}
189
- ```
190
-
191
- ---
192
-
193
- ### Step 6 โ€” Inject admin-configured titles
194
-
195
- Inside your `.map()`, pass an overridden `paymentInfoMap` to the `PaymentContainer` component so the radio button labels reflect whatever the admin has set in Medusa Admin โ†’ Settings โ†’ PayPal โ†’ PayPal Settings:
196
-
197
- ```tsx
198
- <PaymentContainer
199
- paymentInfoMap={{
200
- ...paymentInfoMap,
201
- "pp_paypal_paypal": {
202
- ...(paymentInfoMap["pp_paypal_paypal"] || {}),
203
- title: paypalTitle,
204
- },
205
- "pp_paypal_card_paypal_card": {
206
- ...(paymentInfoMap["pp_paypal_card_paypal_card"] || {}),
207
- title: cardTitle,
208
- },
209
- }}
210
- paymentProviderId={paymentMethod.id}
211
- selectedPaymentOptionId={selectedPaymentMethod}
212
- />
213
- ```
214
-
215
- ---
216
-
217
- ### Step 7 โ€” Render the PayPal UI
218
-
219
- After the closing `</RadioGroup>` tag, add the `PayPalPaymentSection` component:
220
-
221
- ```tsx
222
- <PayPalPaymentSection
223
- cartId={cart.id}
224
- selectedProviderId={selectedPaymentMethod}
225
- baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
226
- publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
227
- sessionLoading={paypalSessionLoading}
228
- onSuccess={async () => {
229
- await placeOrder(cart.id)
230
- }}
231
- onError={(msg) => setError(msg)}
232
- />
233
- ```
234
-
235
- This single component manages everything: config loading, Smart Buttons, Advanced Card fields, spinners, and errors. When a non-PayPal provider is selected it renders nothing, so it is safe to keep it in the tree at all times.
236
-
237
- > **Critical โ€” `onSuccess` must call `placeOrder`**
238
- >
239
- > `placeOrder` is the Next.js Server Action exported from `@lib/data/cart`. It **must** run server-side โ€” it is the only mechanism that can clear the httpOnly cart cookie set by the server. Replacing it with a client-side `fetch` to `/store/carts/:id/complete` will complete the order in Medusa but leave the cart cookie intact, breaking the storefront session.
240
-
241
- ---
242
-
243
- ### Step 8 โ€” Disable the Continue button
244
-
245
- `PayPalPaymentSection` renders its own Pay button. The storefront's "Continue to review" button must be disabled when PayPal is selected to avoid checkout flow confusion.
246
-
247
- Add `isPayPalProviderId(selectedPaymentMethod)` to your `Button` component's `disabled` prop:
248
-
249
- ```tsx
250
- <Button
251
- size="large"
252
- className="mt-6"
253
- onClick={handleSubmit}
254
- isLoading={isLoading}
255
- disabled={
256
- (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
257
- (!selectedPaymentMethod && !paidByGiftcard) ||
258
- isPayPalProviderId(selectedPaymentMethod) // โ† add this line
259
- }
260
- data-testid="submit-payment-button"
261
- >
262
- {!activeSession && isStripeLike(selectedPaymentMethod)
263
- ? "Enter card details"
264
- : "Continue to review"}
265
- </Button>
266
- ```
267
-
268
- ---
269
-
270
- ### Step 9 โ€” Fix the summary label
271
-
272
- In the collapsed summary view (shown after the customer has selected a payment method), replace any hardcoded PayPal label with the admin-configured title from the hook:
273
-
274
- ```tsx
275
- <Text
276
- className="txt-medium text-ui-fg-subtle"
277
- data-testid="payment-method-summary"
278
- >
279
- {activeSession?.provider_id === "pp_paypal_paypal"
280
- ? paypalTitle
281
- : activeSession?.provider_id === "pp_paypal_card_paypal_card"
282
- ? cardTitle
283
- : paymentInfoMap[activeSession?.provider_id]?.title ||
284
- activeSession?.provider_id}
285
- </Text>
286
- ```
287
-
288
- ---
289
-
290
- ## Complete File
291
-
292
- The following is a complete, working replacement for your payment step file with all changes from the guide above already applied. Copy and replace the entire contents of `src/modules/checkout/components/payment/index.tsx`.
293
-
294
- ```tsx
295
- "use client"
296
-
297
- import { RadioGroup } from "@headlessui/react"
298
- import { isStripeLike, paymentInfoMap } from "@lib/constants"
299
- import { initiatePaymentSession, placeOrder } from "@lib/data/cart"
300
- import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
301
- import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
302
- import {
303
- PayPalPaymentSection,
304
- isPayPalProviderId,
305
- usePayPalPaymentMethods,
306
- } from "@easypayment/medusa-paypal-ui"
307
- import ErrorMessage from "@modules/checkout/components/error-message"
308
- import PaymentContainer, {
309
- StripeCardContainer,
310
- } from "@modules/checkout/components/payment-container"
311
- import Divider from "@modules/common/components/divider"
312
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
313
- import { useCallback, useEffect, useState } from "react"
314
-
315
- const Payment = ({
316
- cart,
317
- availablePaymentMethods,
318
- }: {
319
- cart: any
320
- availablePaymentMethods: any[]
321
- }) => {
322
- const activeSession = cart.payment_collection?.payment_sessions?.find(
323
- (paymentSession: any) => paymentSession.status === "pending",
324
- )
325
-
326
- const [isLoading, setIsLoading] = useState(false)
327
- const [error, setError] = useState<string | null>(null)
328
- const [cardBrand, setCardBrand] = useState<string | null>(null)
329
- const [cardComplete, setCardComplete] = useState(false)
330
- const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
331
- activeSession?.provider_id ?? "",
332
- )
333
- const [paypalSessionLoading, setPaypalSessionLoading] = useState(false)
334
-
335
- const searchParams = useSearchParams()
336
- const router = useRouter()
337
- const pathname = usePathname()
338
-
339
- const isOpen = searchParams.get("step") === "payment"
340
-
341
- const { paypalEnabled, paypalTitle, cardEnabled, cardTitle } =
342
- usePayPalPaymentMethods({
343
- baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
344
- publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
345
- cartId: cart.id,
346
- enabled: isOpen,
347
- })
348
-
349
- const setPaymentMethod = async (method: string) => {
350
- setError(null)
351
- setSelectedPaymentMethod(method)
352
- if (isStripeLike(method) || isPayPalProviderId(method)) {
353
- if (isPayPalProviderId(method)) setPaypalSessionLoading(true)
354
- try {
355
- await initiatePaymentSession(cart, { provider_id: method })
356
- } finally {
357
- setPaypalSessionLoading(false)
358
- }
359
- }
360
- }
361
-
362
- const paidByGiftcard =
363
- cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
364
-
365
- const paymentReady =
366
- (activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
367
-
368
- const createQueryString = useCallback(
369
- (name: string, value: string) => {
370
- const params = new URLSearchParams(searchParams)
371
- params.set(name, value)
372
- return params.toString()
373
- },
374
- [searchParams],
375
- )
376
-
377
- const handleEdit = () => {
378
- router.push(pathname + "?" + createQueryString("step", "payment"), {
379
- scroll: false,
380
- })
381
- }
382
-
383
- const handleSubmit = async () => {
384
- setIsLoading(true)
385
- try {
386
- const shouldInputCard =
387
- isStripeLike(selectedPaymentMethod) && !activeSession
388
- const checkActiveSession =
389
- activeSession?.provider_id === selectedPaymentMethod
390
- if (!checkActiveSession) {
391
- await initiatePaymentSession(cart, { provider_id: selectedPaymentMethod })
392
- }
393
- if (!shouldInputCard) {
394
- return router.push(
395
- pathname + "?" + createQueryString("step", "review"),
396
- { scroll: false },
397
- )
398
- }
399
- } catch (err: any) {
400
- setError(err.message)
401
- } finally {
402
- setIsLoading(false)
403
- }
404
- }
405
-
406
- useEffect(() => {
407
- setError(null)
408
- }, [isOpen])
409
-
410
- return (
411
- <div className="bg-white">
412
- <div className="flex flex-row items-center justify-between mb-6">
413
- <Heading
414
- level="h2"
415
- className={clx(
416
- "flex flex-row text-3xl-regular gap-x-2 items-baseline",
417
- {
418
- "opacity-50 pointer-events-none select-none":
419
- !isOpen && !paymentReady,
420
- },
421
- )}
422
- >
423
- Payment
424
- {!isOpen && paymentReady && <CheckCircleSolid />}
425
- </Heading>
426
- {!isOpen && paymentReady && (
427
- <Text>
428
- <button
429
- onClick={handleEdit}
430
- className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
431
- data-testid="edit-payment-button"
432
- >
433
- Edit
434
- </button>
435
- </Text>
436
- )}
437
- </div>
438
-
439
- <div>
440
- <div className={isOpen ? "block" : "hidden"}>
441
- {!paidByGiftcard && availablePaymentMethods?.length && (
442
- <>
443
- <RadioGroup
444
- value={selectedPaymentMethod}
445
- onChange={(value: string) => setPaymentMethod(value)}
446
- >
447
- {availablePaymentMethods
448
- .filter((m) => {
449
- if (m.id === "pp_paypal_paypal" && !paypalEnabled) return false
450
- if (m.id === "pp_paypal_card_paypal_card" && !cardEnabled) return false
451
- return true
452
- })
453
- .map((paymentMethod) => (
454
- <div key={paymentMethod.id}>
455
- {isStripeLike(paymentMethod.id) ? (
456
- <StripeCardContainer
457
- paymentProviderId={paymentMethod.id}
458
- selectedPaymentOptionId={selectedPaymentMethod}
459
- paymentInfoMap={paymentInfoMap}
460
- setCardBrand={setCardBrand}
461
- setError={setError}
462
- setCardComplete={setCardComplete}
463
- />
464
- ) : (
465
- <PaymentContainer
466
- paymentInfoMap={{
467
- ...paymentInfoMap,
468
- "pp_paypal_paypal": {
469
- ...(paymentInfoMap["pp_paypal_paypal"] || {}),
470
- title: paypalTitle,
471
- },
472
- "pp_paypal_card_paypal_card": {
473
- ...(paymentInfoMap["pp_paypal_card_paypal_card"] || {}),
474
- title: cardTitle,
475
- },
476
- }}
477
- paymentProviderId={paymentMethod.id}
478
- selectedPaymentOptionId={selectedPaymentMethod}
479
- />
480
- )}
481
- </div>
482
- ))}
483
- </RadioGroup>
484
-
485
- <PayPalPaymentSection
486
- cartId={cart.id}
487
- selectedProviderId={selectedPaymentMethod}
488
- baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
489
- publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
490
- sessionLoading={paypalSessionLoading}
491
- onSuccess={async () => {
492
- await placeOrder(cart.id)
493
- }}
494
- onError={(msg) => setError(msg)}
495
- />
496
- </>
497
- )}
498
-
499
- {paidByGiftcard && (
500
- <div className="flex flex-col w-1/3">
501
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
502
- Payment method
503
- </Text>
504
- <Text
505
- className="txt-medium text-ui-fg-subtle"
506
- data-testid="payment-method-summary"
507
- >
508
- Gift card
509
- </Text>
510
- </div>
511
- )}
512
-
513
- <ErrorMessage
514
- error={error}
515
- data-testid="payment-method-error-message"
516
- />
517
-
518
- <Button
519
- size="large"
520
- className="mt-6"
521
- onClick={handleSubmit}
522
- isLoading={isLoading}
523
- disabled={
524
- (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
525
- (!selectedPaymentMethod && !paidByGiftcard) ||
526
- isPayPalProviderId(selectedPaymentMethod)
527
- }
528
- data-testid="submit-payment-button"
529
- >
530
- {!activeSession && isStripeLike(selectedPaymentMethod)
531
- ? "Enter card details"
532
- : "Continue to review"}
533
- </Button>
534
- </div>
535
-
536
- <div className={isOpen ? "hidden" : "block"}>
537
- {cart && paymentReady && activeSession ? (
538
- <div className="flex items-start gap-x-1 w-full">
539
- <div className="flex flex-col w-1/3">
540
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
541
- Payment method
542
- </Text>
543
- <Text
544
- className="txt-medium text-ui-fg-subtle"
545
- data-testid="payment-method-summary"
546
- >
547
- {activeSession?.provider_id === "pp_paypal_paypal"
548
- ? paypalTitle
549
- : activeSession?.provider_id === "pp_paypal_card_paypal_card"
550
- ? cardTitle
551
- : paymentInfoMap[activeSession?.provider_id]?.title ||
552
- activeSession?.provider_id}
553
- </Text>
554
- </div>
555
- <div className="flex flex-col w-1/3">
556
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
557
- Payment details
558
- </Text>
559
- <div
560
- className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
561
- data-testid="payment-details-summary"
562
- >
563
- <Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
564
- {paymentInfoMap[selectedPaymentMethod]?.icon || <CreditCard />}
565
- </Container>
566
- <Text>
567
- {isStripeLike(selectedPaymentMethod) && cardBrand
568
- ? cardBrand
569
- : "Another step will appear"}
570
- </Text>
571
- </div>
572
- </div>
573
- </div>
574
- ) : paidByGiftcard ? (
575
- <div className="flex flex-col w-1/3">
576
- <Text className="txt-medium-plus text-ui-fg-base mb-1">
577
- Payment method
578
- </Text>
579
- <Text
580
- className="txt-medium text-ui-fg-subtle"
581
- data-testid="payment-method-summary"
582
- >
583
- Gift card
584
- </Text>
585
- </div>
586
- ) : null}
587
- </div>
588
- </div>
589
- <Divider className="mt-8" />
590
- </div>
591
- )
592
- }
593
-
594
- export default Payment
595
- ```
596
-
597
- ---
598
-
599
- ## Testing
600
-
601
- Toggle between sandbox and live in Medusa Admin โ†’ **Settings โ†’ PayPal โ†’ PayPal Connection โ†’ Environment**.
602
-
603
- **Sandbox buyer account**
604
-
605
- 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.
606
-
607
- **Test card number for Advanced Card fields**
608
-
609
- ```
610
- Card number 4111 1111 1111 1111
611
- Expiry Any future date
612
- CVV Any 3 digits
613
- ```
614
-
615
- ---
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
+
760
+ MIT ยฉ [Easy Payment](https://www.npmjs.com/package/@easypayment/medusa-paypal-ui)