@easypayment/medusa-paypal-ui 1.0.42 โ†’ 1.0.44

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,818 @@
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
+ <div align="center">
2
+
3
+ <h1>๐Ÿ…ฟ medusa-paypal-frontend</h1>
4
+
5
+ <p><strong>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 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.
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 |
29
+ | **Built-in UX** | Smart Buttons and Advanced Card UI are rendered by `MedusaNextPayPalAdapter` |
30
+ | **Storefront-controlled flow** | Your payment step controls session creation, loading states, and `placeOrder` |
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 PayPal helpers and state](#step-2---add-paypal-helpers-and-state)
42
+ - [Step 3 - Load config from /store/paypal/config](#step-3---load-config-from-storepaypalconfig)
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 { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"
112
+ ```
113
+
114
+ ---
115
+
116
+ ### Step 2 - Add PayPal helpers and state
117
+
118
+ Inside the file, add PayPal provider helpers and the local state used by the updated implementation:
119
+
120
+ ```tsx
121
+ const PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
122
+ const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
123
+ const PAYPAL_PROVIDER_IDS = [PAYPAL_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID]
124
+
125
+ const isPayPal = (id: string) => PAYPAL_PROVIDER_IDS.includes(id)
126
+
127
+ const [paypalEnabled, setPaypalEnabled] = useState(true)
128
+ const [paypalTitle, setPaypalTitle] = useState("PayPal")
129
+ const [cardEnabled, setCardEnabled] = useState(true)
130
+ const [cardTitle, setCardTitle] = useState("Credit or Debit Card")
131
+ const [paypalLoading, setPaypalLoading] = useState(false)
132
+ ```
133
+
134
+ ---
135
+
136
+ ### Step 3 - Load config from /store/paypal/config
137
+
138
+ Instead of using a hook, fetch the PayPal config when the payment step is open:
139
+
140
+ ```tsx
141
+ useEffect(() => {
142
+ if (!isOpen) {
143
+ return
144
+ }
145
+
146
+ const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
147
+
148
+ if (!backendUrl) {
149
+ return
150
+ }
151
+
152
+ const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
153
+ const controller = new AbortController()
154
+
155
+ const loadPayPalConfig = async () => {
156
+ try {
157
+ const response = await fetch(`${backendUrl}/store/paypal/config`, {
158
+ headers: key ? { "x-publishable-api-key": key } : {},
159
+ signal: controller.signal,
160
+ })
161
+
162
+ if (response.status === 403) {
163
+ setPaypalEnabled(false)
164
+ setCardEnabled(false)
165
+ return
166
+ }
167
+
168
+ if (!response.ok) {
169
+ return
170
+ }
171
+
172
+ const config = await response.json()
173
+
174
+ if (typeof config?.paypal_enabled === "boolean") {
175
+ setPaypalEnabled(config.paypal_enabled)
176
+ }
177
+
178
+ if (typeof config?.paypal_title === "string" && config.paypal_title) {
179
+ setPaypalTitle(config.paypal_title)
180
+ }
181
+
182
+ if (typeof config?.card_enabled === "boolean") {
183
+ setCardEnabled(config.card_enabled)
184
+ }
185
+
186
+ if (typeof config?.card_title === "string" && config.card_title) {
187
+ setCardTitle(config.card_title)
188
+ }
189
+ } catch (err) {
190
+ if ((err as Error).name !== "AbortError") {
191
+ setPaypalLoading(false)
192
+ }
193
+ }
194
+ }
195
+
196
+ void loadPayPalConfig()
197
+
198
+ return () => controller.abort()
199
+ }, [isOpen])
200
+ ```
201
+
202
+ ---
203
+
204
+ ### Step 4 - Update setPaymentMethod
205
+
206
+ Replace your existing `setPaymentMethod` function with the following. The key addition is setting `paypalLoading` while the PayPal session is being created:
207
+
208
+ ```tsx
209
+ const setPaymentMethod = async (method: string) => {
210
+ setError(null)
211
+ setSelectedPaymentMethod(method)
212
+
213
+ if (!isStripeLike(method) && !isPayPal(method)) {
214
+ return
215
+ }
216
+
217
+ if (isPayPal(method)) {
218
+ setPaypalLoading(true)
219
+ }
220
+
221
+ try {
222
+ await initiatePaymentSession(cart, { provider_id: method })
223
+ } finally {
224
+ if (isPayPal(method)) {
225
+ setPaypalLoading(false)
226
+ }
227
+ }
228
+ }
229
+ ```
230
+
231
+ ---
232
+
233
+ ### Step 5 - Filter the payment method list
234
+
235
+ Add a filtered payment method list before your `RadioGroup` render:
236
+
237
+ ```tsx
238
+ const filteredPaymentMethods = useMemo(
239
+ () =>
240
+ availablePaymentMethods.filter((paymentMethod) => {
241
+ if (paymentMethod.id === PAYPAL_PROVIDER_ID) return paypalEnabled
242
+ if (paymentMethod.id === PAYPAL_CARD_PROVIDER_ID) return cardEnabled
243
+ return true
244
+ }),
245
+ [availablePaymentMethods, cardEnabled, paypalEnabled],
246
+ )
247
+ ```
248
+
249
+ Then use `filteredPaymentMethods.map(...)` instead of rendering `availablePaymentMethods` directly.
250
+
251
+ ---
252
+
253
+ ### Step 6 - Inject admin-configured titles
254
+
255
+ Inside your `.map()`, pass an overridden `paymentInfoMap` to the `PaymentContainer` component so the radio button labels reflect the values set in Medusa Admin:
256
+
257
+ ```tsx
258
+ <PaymentContainer
259
+ paymentInfoMap={{
260
+ ...paymentInfoMap,
261
+ ...(paymentMethod.id === PAYPAL_PROVIDER_ID
262
+ ? {
263
+ [paymentMethod.id]: {
264
+ ...(paymentInfoMap[paymentMethod.id] || {}),
265
+ title: paypalTitle,
266
+ },
267
+ }
268
+ : {}),
269
+ ...(paymentMethod.id === PAYPAL_CARD_PROVIDER_ID
270
+ ? {
271
+ [paymentMethod.id]: {
272
+ ...(paymentInfoMap[paymentMethod.id] || {}),
273
+ title: cardTitle,
274
+ },
275
+ }
276
+ : {}),
277
+ }}
278
+ paymentProviderId={paymentMethod.id}
279
+ selectedPaymentOptionId={selectedPaymentMethod}
280
+ />
281
+ ```
282
+
283
+ ---
284
+
285
+ ### Step 7 - Render the PayPal UI
286
+
287
+ After the closing `</RadioGroup>` tag, render a loading state while the PayPal session is being prepared, then mount `MedusaNextPayPalAdapter`:
288
+
289
+ ```tsx
290
+ {isPayPal(selectedPaymentMethod) && paypalLoading && (
291
+ <div>
292
+ <div>Setting up payment...</div>
293
+ </div>
294
+ )}
295
+
296
+ {isPayPal(selectedPaymentMethod) && !paypalLoading && (
297
+ <MedusaNextPayPalAdapter
298
+ cartId={cart.id}
299
+ selectedProviderId={selectedPaymentMethod}
300
+ baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
301
+ publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
302
+ onSuccess={async () => {
303
+ await placeOrder(cart.id)
304
+ }}
305
+ onError={(message) => setError(message)}
306
+ />
307
+ )}
308
+ ```
309
+
310
+ > **Critical โ€” `onSuccess` must call `placeOrder`**
311
+ >
312
+ > `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.
313
+
314
+ ---
315
+
316
+ ### Step 8 - Disable the Continue button
317
+
318
+ Because PayPal renders its own checkout action, the storefront's "Continue to review" button must be disabled when PayPal is selected.
319
+
320
+ Add `isPayPal(selectedPaymentMethod)` to your `Button` component's `disabled` prop:
321
+
322
+ ```tsx
323
+ <Button
324
+ size="large"
325
+ className="mt-6"
326
+ onClick={handleSubmit}
327
+ isLoading={isLoading}
328
+ disabled={
329
+ (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
330
+ (!selectedPaymentMethod && !paidByGiftcard) ||
331
+ isPayPal(selectedPaymentMethod)
332
+ }
333
+ data-testid="submit-payment-button"
334
+ >
335
+ {!activeSession && isStripeLike(selectedPaymentMethod)
336
+ ? "Enter card details"
337
+ : "Continue to review"}
338
+ </Button>
339
+ ```
340
+
341
+ ---
342
+
343
+ ### Step 9 - Fix the summary label
344
+
345
+ In the collapsed summary view (shown after the customer has selected a payment method), replace any hardcoded PayPal label with the admin-configured title:
346
+
347
+ ```tsx
348
+ <Text
349
+ className="txt-medium text-ui-fg-subtle"
350
+ data-testid="payment-method-summary"
351
+ >
352
+ {activeSession?.provider_id === "pp_paypal_paypal"
353
+ ? paypalTitle
354
+ : activeSession?.provider_id === "pp_paypal_card_paypal_card"
355
+ ? cardTitle
356
+ : paymentInfoMap[activeSession?.provider_id]?.title ||
357
+ activeSession?.provider_id}
358
+ </Text>
359
+ ```
360
+
361
+ ---
362
+
363
+ ## Complete File
364
+
365
+ 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`.
366
+
367
+ ```tsx
368
+ "use client"
369
+
370
+ import { RadioGroup } from "@headlessui/react"
371
+ import { initiatePaymentSession, placeOrder } from "@lib/data/cart"
372
+ import { isStripeLike, paymentInfoMap } from "@lib/constants"
373
+ import { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"
374
+ import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
375
+ import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
376
+ import ErrorMessage from "@modules/checkout/components/error-message"
377
+ import PaymentContainer, {
378
+ StripeCardContainer,
379
+ } from "@modules/checkout/components/payment-container"
380
+ import Divider from "@modules/common/components/divider"
381
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
382
+ import { useCallback, useEffect, useMemo, useState } from "react"
383
+
384
+ const PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
385
+ const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
386
+ const PAYPAL_PROVIDER_IDS = [PAYPAL_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID]
387
+
388
+ const isPayPal = (id: string) => PAYPAL_PROVIDER_IDS.includes(id)
389
+
390
+ const Payment = ({
391
+ cart,
392
+ availablePaymentMethods,
393
+ }: {
394
+ cart: any
395
+ availablePaymentMethods: any[]
396
+ }) => {
397
+ const activeSession = cart.payment_collection?.payment_sessions?.find(
398
+ (paymentSession: any) => paymentSession.status === "pending",
399
+ )
400
+
401
+ const [isLoading, setIsLoading] = useState(false)
402
+ const [error, setError] = useState<string | null>(null)
403
+ const [cardBrand, setCardBrand] = useState<string | null>(null)
404
+ const [cardComplete, setCardComplete] = useState(false)
405
+ const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
406
+ activeSession?.provider_id ?? "",
407
+ )
408
+ const [paypalEnabled, setPaypalEnabled] = useState(true)
409
+ const [paypalTitle, setPaypalTitle] = useState("PayPal")
410
+ const [cardEnabled, setCardEnabled] = useState(true)
411
+ const [cardTitle, setCardTitle] = useState("Credit or Debit Card")
412
+ const [paypalLoading, setPaypalLoading] = useState(false)
413
+
414
+ const searchParams = useSearchParams()
415
+ const router = useRouter()
416
+ const pathname = usePathname()
417
+
418
+ const isOpen = searchParams.get("step") === "payment"
419
+
420
+ const filteredPaymentMethods = useMemo(
421
+ () =>
422
+ availablePaymentMethods.filter((paymentMethod) => {
423
+ if (paymentMethod.id === PAYPAL_PROVIDER_ID) {
424
+ return paypalEnabled
425
+ }
426
+
427
+ if (paymentMethod.id === PAYPAL_CARD_PROVIDER_ID) {
428
+ return cardEnabled
429
+ }
430
+
431
+ return true
432
+ }),
433
+ [availablePaymentMethods, cardEnabled, paypalEnabled],
434
+ )
435
+
436
+ const setPaymentMethod = async (method: string) => {
437
+ setError(null)
438
+ setSelectedPaymentMethod(method)
439
+
440
+ if (!isStripeLike(method) && !isPayPal(method)) {
441
+ return
442
+ }
443
+
444
+ if (isPayPal(method)) {
445
+ setPaypalLoading(true)
446
+ }
447
+
448
+ try {
449
+ await initiatePaymentSession(cart, { provider_id: method })
450
+ } finally {
451
+ if (isPayPal(method)) {
452
+ setPaypalLoading(false)
453
+ }
454
+ }
455
+ }
456
+
457
+ const paidByGiftcard =
458
+ cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
459
+
460
+ const paymentReady =
461
+ (activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
462
+
463
+ const createQueryString = useCallback(
464
+ (name: string, value: string) => {
465
+ const params = new URLSearchParams(searchParams)
466
+ params.set(name, value)
467
+ return params.toString()
468
+ },
469
+ [searchParams],
470
+ )
471
+
472
+ const handleEdit = () => {
473
+ router.push(pathname + "?" + createQueryString("step", "payment"), {
474
+ scroll: false,
475
+ })
476
+ }
477
+
478
+ const handleSubmit = async () => {
479
+ setIsLoading(true)
480
+
481
+ try {
482
+ const shouldInputCard =
483
+ isStripeLike(selectedPaymentMethod) && !activeSession
484
+ const checkActiveSession =
485
+ activeSession?.provider_id === selectedPaymentMethod
486
+
487
+ if (!checkActiveSession) {
488
+ await initiatePaymentSession(cart, {
489
+ provider_id: selectedPaymentMethod,
490
+ })
491
+ }
492
+
493
+ if (!shouldInputCard) {
494
+ return router.push(
495
+ pathname + "?" + createQueryString("step", "review"),
496
+ { scroll: false },
497
+ )
498
+ }
499
+ } catch (err: any) {
500
+ setError(err.message)
501
+ } finally {
502
+ setIsLoading(false)
503
+ }
504
+ }
505
+
506
+ useEffect(() => {
507
+ setError(null)
508
+ }, [isOpen])
509
+
510
+ useEffect(() => {
511
+ if (!isOpen) {
512
+ return
513
+ }
514
+
515
+ const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
516
+
517
+ if (!backendUrl) {
518
+ return
519
+ }
520
+
521
+ const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
522
+ const controller = new AbortController()
523
+
524
+ const loadPayPalConfig = async () => {
525
+ try {
526
+ const response = await fetch(`${backendUrl}/store/paypal/config`, {
527
+ headers: key ? { "x-publishable-api-key": key } : {},
528
+ signal: controller.signal,
529
+ })
530
+
531
+ if (response.status === 403) {
532
+ setPaypalEnabled(false)
533
+ setCardEnabled(false)
534
+ return
535
+ }
536
+
537
+ if (!response.ok) {
538
+ return
539
+ }
540
+
541
+ const config = await response.json()
542
+
543
+ if (typeof config?.paypal_enabled === "boolean") {
544
+ setPaypalEnabled(config.paypal_enabled)
545
+ }
546
+
547
+ if (typeof config?.paypal_title === "string" && config.paypal_title) {
548
+ setPaypalTitle(config.paypal_title)
549
+ }
550
+
551
+ if (typeof config?.card_enabled === "boolean") {
552
+ setCardEnabled(config.card_enabled)
553
+ }
554
+
555
+ if (typeof config?.card_title === "string" && config.card_title) {
556
+ setCardTitle(config.card_title)
557
+ }
558
+ } catch (err) {
559
+ if ((err as Error).name !== "AbortError") {
560
+ setPaypalLoading(false)
561
+ }
562
+ }
563
+ }
564
+
565
+ void loadPayPalConfig()
566
+
567
+ return () => controller.abort()
568
+ }, [isOpen])
569
+
570
+ return (
571
+ <div className="bg-white">
572
+ <div className="flex flex-row items-center justify-between mb-6">
573
+ <Heading
574
+ level="h2"
575
+ className={clx(
576
+ "flex flex-row text-3xl-regular gap-x-2 items-baseline",
577
+ {
578
+ "opacity-50 pointer-events-none select-none":
579
+ !isOpen && !paymentReady,
580
+ },
581
+ )}
582
+ >
583
+ Payment
584
+ {!isOpen && paymentReady && <CheckCircleSolid />}
585
+ </Heading>
586
+ {!isOpen && paymentReady && (
587
+ <Text>
588
+ <button
589
+ onClick={handleEdit}
590
+ className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
591
+ data-testid="edit-payment-button"
592
+ >
593
+ Edit
594
+ </button>
595
+ </Text>
596
+ )}
597
+ </div>
598
+
599
+ <div>
600
+ <div className={isOpen ? "block" : "hidden"}>
601
+ {!paidByGiftcard &&
602
+ filteredPaymentMethods.length > 0 &&
603
+ (paypalEnabled ||
604
+ cardEnabled ||
605
+ availablePaymentMethods.some((method) => !isPayPal(method.id))) && (
606
+ <>
607
+ <RadioGroup
608
+ value={selectedPaymentMethod}
609
+ onChange={(value: string) => setPaymentMethod(value)}
610
+ >
611
+ {filteredPaymentMethods.map((paymentMethod) => (
612
+ <div key={paymentMethod.id}>
613
+ {isStripeLike(paymentMethod.id) ? (
614
+ <StripeCardContainer
615
+ paymentProviderId={paymentMethod.id}
616
+ selectedPaymentOptionId={selectedPaymentMethod}
617
+ paymentInfoMap={paymentInfoMap}
618
+ setCardBrand={setCardBrand}
619
+ setError={setError}
620
+ setCardComplete={setCardComplete}
621
+ />
622
+ ) : (
623
+ <PaymentContainer
624
+ paymentInfoMap={{
625
+ ...paymentInfoMap,
626
+ ...(paymentMethod.id === PAYPAL_PROVIDER_ID
627
+ ? {
628
+ [paymentMethod.id]: {
629
+ ...(paymentInfoMap[paymentMethod.id] || {}),
630
+ title: paypalTitle,
631
+ },
632
+ }
633
+ : {}),
634
+ ...(paymentMethod.id === PAYPAL_CARD_PROVIDER_ID
635
+ ? {
636
+ [paymentMethod.id]: {
637
+ ...(paymentInfoMap[paymentMethod.id] || {}),
638
+ title: cardTitle,
639
+ },
640
+ }
641
+ : {}),
642
+ }}
643
+ paymentProviderId={paymentMethod.id}
644
+ selectedPaymentOptionId={selectedPaymentMethod}
645
+ />
646
+ )}
647
+ </div>
648
+ ))}
649
+ </RadioGroup>
650
+
651
+ {isPayPal(selectedPaymentMethod) && paypalLoading && (
652
+ <div
653
+ style={{
654
+ display: "flex",
655
+ alignItems: "center",
656
+ gap: 12,
657
+ padding: "14px 16px",
658
+ marginTop: 8,
659
+ background: "#f9fafb",
660
+ border: "1px solid #e5e7eb",
661
+ borderRadius: 10,
662
+ }}
663
+ >
664
+ <style>{`@keyframes _idx_spin{to{transform:rotate(360deg)}}`}</style>
665
+ <div
666
+ style={{
667
+ width: 20,
668
+ height: 20,
669
+ borderRadius: "50%",
670
+ border: "2.5px solid #e5e7eb",
671
+ borderTopColor: "#0070ba",
672
+ animation: "_idx_spin .7s linear infinite",
673
+ flexShrink: 0,
674
+ }}
675
+ />
676
+ <div
677
+ style={{
678
+ fontSize: 13,
679
+ fontWeight: 500,
680
+ color: "#111827",
681
+ }}
682
+ >
683
+ Setting up payment...
684
+ </div>
685
+ </div>
686
+ )}
687
+ {isPayPal(selectedPaymentMethod) && !paypalLoading && (
688
+ <MedusaNextPayPalAdapter
689
+ cartId={cart.id}
690
+ selectedProviderId={selectedPaymentMethod}
691
+ baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
692
+ publishableApiKey={
693
+ process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
694
+ }
695
+ onSuccess={async () => {
696
+ await placeOrder(cart.id)
697
+ }}
698
+ onError={(message) => setError(message)}
699
+ />
700
+ )}
701
+ </>
702
+ )}
703
+
704
+ {paidByGiftcard && (
705
+ <div className="flex flex-col w-1/3">
706
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
707
+ Payment method
708
+ </Text>
709
+ <Text
710
+ className="txt-medium text-ui-fg-subtle"
711
+ data-testid="payment-method-summary"
712
+ >
713
+ Gift card
714
+ </Text>
715
+ </div>
716
+ )}
717
+
718
+ <ErrorMessage
719
+ error={error}
720
+ data-testid="payment-method-error-message"
721
+ />
722
+
723
+ <Button
724
+ size="large"
725
+ className="mt-6"
726
+ onClick={handleSubmit}
727
+ isLoading={isLoading}
728
+ disabled={
729
+ (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
730
+ (!selectedPaymentMethod && !paidByGiftcard) ||
731
+ isPayPal(selectedPaymentMethod)
732
+ }
733
+ data-testid="submit-payment-button"
734
+ >
735
+ {!activeSession && isStripeLike(selectedPaymentMethod)
736
+ ? "Enter card details"
737
+ : "Continue to review"}
738
+ </Button>
739
+ </div>
740
+
741
+ <div className={isOpen ? "hidden" : "block"}>
742
+ {cart && paymentReady && activeSession ? (
743
+ <div className="flex items-start gap-x-1 w-full">
744
+ <div className="flex flex-col w-1/3">
745
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
746
+ Payment method
747
+ </Text>
748
+ <Text
749
+ className="txt-medium text-ui-fg-subtle"
750
+ data-testid="payment-method-summary"
751
+ >
752
+ {activeSession?.provider_id === "pp_paypal_paypal"
753
+ ? paypalTitle
754
+ : activeSession?.provider_id === "pp_paypal_card_paypal_card"
755
+ ? cardTitle
756
+ : paymentInfoMap[activeSession?.provider_id]?.title ||
757
+ activeSession?.provider_id}
758
+ </Text>
759
+ </div>
760
+ <div className="flex flex-col w-1/3">
761
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
762
+ Payment details
763
+ </Text>
764
+ <div
765
+ className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
766
+ data-testid="payment-details-summary"
767
+ >
768
+ <Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
769
+ {paymentInfoMap[selectedPaymentMethod]?.icon || <CreditCard />}
770
+ </Container>
771
+ <Text>
772
+ {isStripeLike(selectedPaymentMethod) && cardBrand
773
+ ? cardBrand
774
+ : "Another step will appear"}
775
+ </Text>
776
+ </div>
777
+ </div>
778
+ </div>
779
+ ) : paidByGiftcard ? (
780
+ <div className="flex flex-col w-1/3">
781
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
782
+ Payment method
783
+ </Text>
784
+ <Text
785
+ className="txt-medium text-ui-fg-subtle"
786
+ data-testid="payment-method-summary"
787
+ >
788
+ Gift card
789
+ </Text>
790
+ </div>
791
+ ) : null}
792
+ </div>
793
+ </div>
794
+ <Divider className="mt-8" />
795
+ </div>
796
+ )
797
+ }
798
+
799
+ export default Payment
800
+ ```
801
+
802
+ ---
803
+
804
+ ## Testing
805
+
806
+ Toggle between sandbox and live in Medusa Admin โ†’ **Settings โ†’ PayPal โ†’ PayPal Connection โ†’ Environment**.
807
+
808
+ **Sandbox buyer account**
809
+
810
+ 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.
811
+
812
+ **Test card number for Advanced Card fields**
813
+
814
+ ```
815
+ Card number 4111 1111 1111 1111
816
+ Expiry Any future date
817
+ CVV Any 3 digits
818
+ ```