@easypayment/medusa-paypal-ui 1.0.39 โ†’ 1.0.41

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,118 +1,295 @@
1
- # @easypayment/medusa-paypal-ui
1
+ <div align="center">
2
2
 
3
- PayPal checkout UI for Medusa v2 storefronts.
3
+ <h1>๐Ÿ…ฟ medusa-paypal-frontend</h1>
4
4
 
5
- A React storefront package for integrating **PayPal Wallet** and **PayPal Advanced Card Payments** into a Medusa + Next.js checkout.
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>
6
17
 
7
18
  ---
8
19
 
9
20
  ## Overview
10
21
 
11
- `@easypayment/medusa-paypal-ui` is used on the **storefront** side and works with the backend package:
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.
12
23
 
13
- - `@easypayment/medusa-paypal`
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 |
14
31
 
15
- It is designed to plug into the Medusa Next.js starter checkout and adds:
32
+ ---
16
33
 
17
- - PayPal Wallet rendering in checkout
18
- - PayPal Advanced Card rendering in checkout
19
- - runtime config loading from the Medusa backend
20
- - PayPal-specific payment session initialization
21
- - success and error callbacks for order placement
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)
22
51
 
23
52
  ---
24
53
 
25
- ## Compatibility
54
+ ## Requirements
26
55
 
27
- | Component | Version |
28
- |---|---|
29
- | Next.js | `15.3.9` |
30
- | React | `19.0.4` |
31
- | Storefront starter | `medusa-next@1.0.3` |
32
- | UI package | `@easypayment/medusa-paypal-ui@1.0.32` |
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
33
60
 
34
61
  ---
35
62
 
36
63
  ## Installation
37
64
 
38
- Install the UI package in your storefront:
39
-
40
65
  ```bash
41
66
  npm install @easypayment/medusa-paypal-ui
42
67
  ```
43
68
 
44
- If your project does not already include the PayPal React SDK, install it as well:
45
-
46
69
  ```bash
47
- npm install @paypal/react-paypal-js
70
+ yarn add @easypayment/medusa-paypal-ui
48
71
  ```
49
72
 
50
- Or with pnpm:
73
+ ---
74
+
75
+ ## Environment Variables
51
76
 
52
- ```bash
53
- pnpm add @easypayment/medusa-paypal-ui @paypal/react-paypal-js
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_...
54
87
  ```
55
88
 
56
- Or with yarn:
89
+ > **Where to get the publishable key:**
90
+ > Medusa Admin โ†’ **Settings โ†’ API Key Management โ†’ Create API Key**
57
91
 
58
- ```bash
59
- yarn add @easypayment/medusa-paypal-ui @paypal/react-paypal-js
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
60
100
  ```
61
101
 
102
+ > **Prefer copy-paste?** Skip to [Complete File](#complete-file) for a ready-to-use drop-in replacement.
103
+
62
104
  ---
63
105
 
64
- ## Storefront Environment Variables
106
+ ### Step 1 โ€” Add the import
65
107
 
66
- Add the required variables to your storefront `.env.local` or `.env` file:
108
+ Add this import alongside your existing imports at the top of the file:
67
109
 
68
- ```env
69
- MEDUSA_BACKEND_URL=http://localhost:9000
70
- NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
71
- NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_your_publishable_key
72
- NEXT_PUBLIC_BASE_URL=http://localhost:8000
73
- NEXT_PUBLIC_DEFAULT_REGION=us
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)
74
126
  ```
75
127
 
76
- Other storefront variables such as Stripe, revalidation, or cloud image settings are unrelated to this PayPal package and can stay as they are in the Medusa starter template.
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.
77
129
 
78
130
  ---
79
131
 
80
- ## Backend Requirements
132
+ ### Step 3 โ€” Add the config hook
81
133
 
82
- Your Medusa backend must:
134
+ Add this hook call inside the component, directly after the `const isOpen = ...` line:
83
135
 
84
- - have `@easypayment/medusa-paypal` installed
85
- - register both PayPal providers
86
- - expose `/store/paypal/config`
87
- - have PayPal configured from Medusa Admin
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
+ ```
88
145
 
89
- Typical backend provider IDs:
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.
90
147
 
91
- ```txt
92
- pp_paypal_paypal
93
- pp_paypal_card_paypal_card
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
+ }
94
169
  ```
95
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
+
96
173
  ---
97
174
 
98
- ## What Changed in Checkout Integration
175
+ ### Step 5 โ€” Filter the payment method list
99
176
 
100
- Compared with the original Medusa starter payment step, the PayPal integration adds the following behavior:
177
+ In your `RadioGroup`, add a `.filter()` before `.map()` to hide providers that have been disabled by the admin in the PayPal settings panel:
101
178
 
102
- - imports `MedusaNextPayPalAdapter`
103
- - imports `placeOrder` for PayPal success completion
104
- - defines PayPal provider IDs
105
- - initializes payment sessions for PayPal methods
106
- - fetches runtime config from `/store/paypal/config`
107
- - hides PayPal methods when backend config disables them
108
- - uses backend titles for PayPal Wallet and Card
109
- - disables the default submit button for PayPal because PayPal handles its own submit flow
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
+ ```
110
190
 
111
191
  ---
112
192
 
113
- ## Checkout Integration Example
193
+ ### Step 6 โ€” Inject admin-configured titles
114
194
 
115
- Below is the updated payment-step pattern based on the current storefront integration:
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`.
116
293
 
117
294
  ```tsx
118
295
  "use client"
@@ -122,7 +299,11 @@ import { isStripeLike, paymentInfoMap } from "@lib/constants"
122
299
  import { initiatePaymentSession, placeOrder } from "@lib/data/cart"
123
300
  import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
124
301
  import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
125
- import { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"
302
+ import {
303
+ PayPalPaymentSection,
304
+ isPayPalProviderId,
305
+ usePayPalPaymentMethods,
306
+ } from "@easypayment/medusa-paypal-ui"
126
307
  import ErrorMessage from "@modules/checkout/components/error-message"
127
308
  import PaymentContainer, {
128
309
  StripeCardContainer,
@@ -131,9 +312,6 @@ import Divider from "@modules/common/components/divider"
131
312
  import { usePathname, useRouter, useSearchParams } from "next/navigation"
132
313
  import { useCallback, useEffect, useState } from "react"
133
314
 
134
- const PAYPAL_IDS = ["pp_paypal_paypal", "pp_paypal_card_paypal_card"]
135
- const isPayPal = (id: string) => PAYPAL_IDS.includes(id)
136
-
137
315
  const Payment = ({
138
316
  cart,
139
317
  availablePaymentMethods,
@@ -142,7 +320,7 @@ const Payment = ({
142
320
  availablePaymentMethods: any[]
143
321
  }) => {
144
322
  const activeSession = cart.payment_collection?.payment_sessions?.find(
145
- (paymentSession: any) => paymentSession.status === "pending"
323
+ (paymentSession: any) => paymentSession.status === "pending",
146
324
  )
147
325
 
148
326
  const [isLoading, setIsLoading] = useState(false)
@@ -150,12 +328,9 @@ const Payment = ({
150
328
  const [cardBrand, setCardBrand] = useState<string | null>(null)
151
329
  const [cardComplete, setCardComplete] = useState(false)
152
330
  const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
153
- activeSession?.provider_id ?? ""
331
+ activeSession?.provider_id ?? "",
154
332
  )
155
- const [paypalEnabled, setPaypalEnabled] = useState<boolean>(true)
156
- const [paypalTitle, setPaypalTitle] = useState<string>("PayPal")
157
- const [cardEnabled, setCardEnabled] = useState<boolean>(true)
158
- const [cardTitle, setCardTitle] = useState<string>("Credit or Debit Card")
333
+ const [paypalSessionLoading, setPaypalSessionLoading] = useState(false)
159
334
 
160
335
  const searchParams = useSearchParams()
161
336
  const router = useRouter()
@@ -163,12 +338,24 @@ const Payment = ({
163
338
 
164
339
  const isOpen = searchParams.get("step") === "payment"
165
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
+
166
349
  const setPaymentMethod = async (method: string) => {
167
350
  setError(null)
168
351
  setSelectedPaymentMethod(method)
169
-
170
- if (isStripeLike(method) || isPayPal(method)) {
171
- await initiatePaymentSession(cart, { provider_id: 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
+ }
172
359
  }
173
360
  }
174
361
 
@@ -184,7 +371,7 @@ const Payment = ({
184
371
  params.set(name, value)
185
372
  return params.toString()
186
373
  },
187
- [searchParams]
374
+ [searchParams],
188
375
  )
189
376
 
190
377
  const handleEdit = () => {
@@ -198,22 +385,15 @@ const Payment = ({
198
385
  try {
199
386
  const shouldInputCard =
200
387
  isStripeLike(selectedPaymentMethod) && !activeSession
201
-
202
388
  const checkActiveSession =
203
389
  activeSession?.provider_id === selectedPaymentMethod
204
-
205
390
  if (!checkActiveSession) {
206
- await initiatePaymentSession(cart, {
207
- provider_id: selectedPaymentMethod,
208
- })
391
+ await initiatePaymentSession(cart, { provider_id: selectedPaymentMethod })
209
392
  }
210
-
211
393
  if (!shouldInputCard) {
212
394
  return router.push(
213
395
  pathname + "?" + createQueryString("step", "review"),
214
- {
215
- scroll: false,
216
- }
396
+ { scroll: false },
217
397
  )
218
398
  }
219
399
  } catch (err: any) {
@@ -227,52 +407,6 @@ const Payment = ({
227
407
  setError(null)
228
408
  }, [isOpen])
229
409
 
230
- useEffect(() => {
231
- if (!isOpen) return
232
-
233
- const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
234
- if (!backendUrl) return
235
-
236
- const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
237
-
238
- fetch(`${backendUrl}/store/paypal/config`, {
239
- headers: key ? { "x-publishable-api-key": key } : {},
240
- })
241
- .then((r) => {
242
- if (r.status === 403) {
243
- setPaypalEnabled(false)
244
- setCardEnabled(false)
245
- return null
246
- }
247
-
248
- if (!r.ok) {
249
- return null
250
- }
251
-
252
- return r.json()
253
- })
254
- .then((cfg) => {
255
- if (!cfg) return
256
-
257
- if (typeof cfg?.paypal_enabled === "boolean") {
258
- setPaypalEnabled(cfg.paypal_enabled)
259
- }
260
-
261
- if (typeof cfg?.paypal_title === "string" && cfg.paypal_title) {
262
- setPaypalTitle(cfg.paypal_title)
263
- }
264
-
265
- if (typeof cfg?.card_enabled === "boolean") {
266
- setCardEnabled(cfg.card_enabled)
267
- }
268
-
269
- if (typeof cfg?.card_title === "string" && cfg.card_title) {
270
- setCardTitle(cfg.card_title)
271
- }
272
- })
273
- .catch(() => {})
274
- }, [isOpen])
275
-
276
410
  return (
277
411
  <div className="bg-white">
278
412
  <div className="flex flex-row items-center justify-between mb-6">
@@ -283,13 +417,12 @@ const Payment = ({
283
417
  {
284
418
  "opacity-50 pointer-events-none select-none":
285
419
  !isOpen && !paymentReady,
286
- }
420
+ },
287
421
  )}
288
422
  >
289
423
  Payment
290
424
  {!isOpen && paymentReady && <CheckCircleSolid />}
291
425
  </Heading>
292
-
293
426
  {!isOpen && paymentReady && (
294
427
  <Text>
295
428
  <button
@@ -305,69 +438,77 @@ const Payment = ({
305
438
 
306
439
  <div>
307
440
  <div className={isOpen ? "block" : "hidden"}>
308
- {!paidByGiftcard &&
309
- availablePaymentMethods?.length &&
310
- (paypalEnabled ||
311
- cardEnabled ||
312
- availablePaymentMethods.some((m) => !isPayPal(m.id))) && (
313
- <>
314
- <RadioGroup
315
- value={selectedPaymentMethod}
316
- onChange={(value: string) => setPaymentMethod(value)}
317
- >
318
- {availablePaymentMethods
319
- .filter((m) => {
320
- if (m.id === "pp_paypal_paypal" && !paypalEnabled) return false
321
- if (m.id === "pp_paypal_card_paypal_card" && !cardEnabled) return false
322
- return true
323
- })
324
- .map((paymentMethod) => (
325
- <div key={paymentMethod.id}>
326
- {isStripeLike(paymentMethod.id) ? (
327
- <StripeCardContainer
328
- paymentProviderId={paymentMethod.id}
329
- selectedPaymentOptionId={selectedPaymentMethod}
330
- paymentInfoMap={paymentInfoMap}
331
- setCardBrand={setCardBrand}
332
- setError={setError}
333
- setCardComplete={setCardComplete}
334
- />
335
- ) : (
336
- <PaymentContainer
337
- paymentInfoMap={{
338
- ...paymentInfoMap,
339
- ...(isPayPal(paymentMethod.id)
340
- ? {
341
- [paymentMethod.id]: {
342
- ...(paymentInfoMap[paymentMethod.id] || {}),
343
- title:
344
- paymentMethod.id === "pp_paypal_card_paypal_card"
345
- ? cardTitle
346
- : paypalTitle,
347
- },
348
- }
349
- : {}),
350
- }}
351
- paymentProviderId={paymentMethod.id}
352
- selectedPaymentOptionId={selectedPaymentMethod}
353
- />
354
- )}
355
- </div>
356
- ))}
357
- </RadioGroup>
358
-
359
- {isPayPal(selectedPaymentMethod) && (
360
- <MedusaNextPayPalAdapter
361
- cartId={cart.id}
362
- selectedProviderId={selectedPaymentMethod}
363
- baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
364
- publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
365
- onSuccess={() => placeOrder(cart.id)}
366
- onError={(msg) => setError(msg)}
367
- />
368
- )}
369
- </>
370
- )}
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
+ )}
371
512
 
372
513
  <ErrorMessage
373
514
  error={error}
@@ -382,17 +523,69 @@ const Payment = ({
382
523
  disabled={
383
524
  (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
384
525
  (!selectedPaymentMethod && !paidByGiftcard) ||
385
- isPayPal(selectedPaymentMethod)
526
+ isPayPalProviderId(selectedPaymentMethod)
386
527
  }
387
528
  data-testid="submit-payment-button"
388
529
  >
389
530
  {!activeSession && isStripeLike(selectedPaymentMethod)
390
- ? " Enter card details"
531
+ ? "Enter card details"
391
532
  : "Continue to review"}
392
533
  </Button>
393
534
  </div>
394
- </div>
395
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>
396
589
  <Divider className="mt-8" />
397
590
  </div>
398
591
  )
@@ -403,102 +596,20 @@ export default Payment
403
596
 
404
597
  ---
405
598
 
406
- ## Adapter Usage
599
+ ## Testing
407
600
 
408
- Render the adapter only when the selected payment method is a PayPal provider:
601
+ Toggle between sandbox and live in Medusa Admin โ†’ **Settings โ†’ PayPal โ†’ PayPal Connection โ†’ Environment**.
409
602
 
410
- ```tsx
411
- {isPayPal(selectedPaymentMethod) && (
412
- <MedusaNextPayPalAdapter
413
- cartId={cart.id}
414
- selectedProviderId={selectedPaymentMethod}
415
- baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
416
- publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
417
- onSuccess={() => placeOrder(cart.id)}
418
- onError={(msg) => setError(msg)}
419
- />
420
- )}
421
- ```
422
-
423
- ---
603
+ **Sandbox buyer account**
424
604
 
425
- ## Runtime Config Endpoint
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.
426
606
 
427
- The storefront reads PayPal runtime settings from:
607
+ **Test card number for Advanced Card fields**
428
608
 
429
- ```txt
430
- /store/paypal/config
431
609
  ```
432
-
433
- This endpoint is used to control:
434
-
435
- - `paypal_enabled`
436
- - `paypal_title`
437
- - `card_enabled`
438
- - `card_title`
439
-
440
- If the endpoint returns `403`, the storefront should treat both PayPal methods as disabled.
441
-
442
- ---
443
-
444
- ## Props
445
-
446
- | Prop | Required | Description |
447
- |---|---|---|
448
- | `cartId` | Yes | Active cart ID |
449
- | `selectedProviderId` | Yes | Currently selected payment provider ID |
450
- | `baseUrl` | Yes | Medusa backend base URL |
451
- | `publishableApiKey` | No | Publishable API key sent in config request |
452
- | `providerIds` | No | Override default PayPal provider IDs |
453
- | `onSuccess` | No | Called after successful PayPal checkout |
454
- | `onError` | No | Called when PayPal returns an error |
455
-
456
- ---
457
-
458
- ## Default Provider Mapping
459
-
460
- The UI package uses these default provider IDs:
461
-
462
- ```ts
463
- {
464
- paypal: "pp_paypal_paypal",
465
- paypalCard: "pp_paypal_card_paypal_card"
466
- }
610
+ Card number 4111 1111 1111 1111
611
+ Expiry Any future date
612
+ CVV Any 3 digits
467
613
  ```
468
614
 
469
- If your backend uses custom IDs, pass them through `providerIds`.
470
-
471
- ---
472
-
473
- ## Notes for Medusa Starter Users
474
-
475
- The original Medusa starter payment step only initialized Stripe-like sessions and used the default review-step submit flow.
476
-
477
- To support PayPal, your updated checkout should additionally:
478
-
479
- - initialize sessions for PayPal methods
480
- - render `MedusaNextPayPalAdapter`
481
- - call `placeOrder` after successful PayPal completion
482
- - disable the normal submit button while PayPal is selected
483
- - optionally fetch backend config to control PayPal method visibility and labels
484
-
485
- ---
486
-
487
- ## Validation Checklist
488
-
489
- - Install `@easypayment/medusa-paypal-ui`
490
- - Install `@paypal/react-paypal-js`
491
- - Set storefront environment variables
492
- - Configure backend PayPal package
493
- - Ensure provider IDs match backend configuration
494
- - Update checkout payment step
495
- - Verify PayPal Wallet flow
496
- - Verify PayPal Card flow
497
- - Verify order placement after successful payment
498
- - Verify error handling
499
-
500
- ---
501
-
502
- ## License
503
-
504
- MIT
615
+ ---