@easypayment/medusa-paypal-ui
v1.0.45
Published
Enterprise Gold PayPal UI module for Medusa v2 storefront (Next.js)
Downloads
376
Readme
PayPal for Medusa Frontend UI
PayPal checkout UI for Medusa v2 storefronts — Smart Buttons, Advanced Card Fields
📋 Table of Contents
- 📦 Overview
- ✅ Requirements
- 🚀 Installation
- 🔑 Environment Variables
- 🔗 Integration Guide
- 📄 Complete File
- 🧪 Testing
- 📄 License
📦 Overview
@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.
| Feature | Details |
|---|---|
| 🔵 PayPal Smart Buttons | Wallet-based checkout via pp_paypal_paypal |
| 💳 Advanced Card Fields | Hosted PCI-compliant advanced credit card inputs via pp_paypal_card_paypal_card |
| 🛠 Admin-driven config | Enable/disable providers and set labels from Medusa Admin |
| ⚡ Built-in UX | Smart Buttons and Advanced Card UI rendered by MedusaNextPayPalAdapter |
| 🔄 Storefront-controlled flow | Your payment step controls session creation, loading states, and placeOrder |
✅ Requirements
- Node.js 18+
- Next.js 14+ with App Router
@easypayment/medusa-paypalinstalled and running on your Medusa server- A PayPal account connected in Medusa Admin → Settings → PayPal → PayPal Connection
🚀 Installation
In your storefront directory, run:
npm install @easypayment/medusa-paypal-ui🔑 Environment Variables
Add the following to your storefront .env.local. Use separate values for development and production.
NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_...Where to get the publishable key: Medusa Admin → Settings → API Key Management → Create API Key
🔗 Integration Guide
All changes in this guide are made to one single file in your storefront:
src/modules/checkout/components/payment/index.tsxOpen that file and follow each step in order.
Prefer to copy-paste the whole file? Skip straight to Complete File and replace the entire contents in one go. The complete file has all 9 steps already applied.
Step 1 — Add the import
Where: At the very top of the file, alongside your other imports.
import { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"Step 2 — Add PayPal helpers and state
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.
// Outside the component — add these constants
const PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
const PAYPAL_PROVIDER_IDS = [PAYPAL_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID]
const isPayPal = (id: string) => PAYPAL_PROVIDER_IDS.includes(id)// Inside the Payment component — add alongside your other useState declarations
const [paypalEnabled, setPaypalEnabled] = useState(true)
const [paypalTitle, setPaypalTitle] = useState("PayPal")
const [cardEnabled, setCardEnabled] = useState(true)
const [cardTitle, setCardTitle] = useState("Credit or Debit Card")
const [paypalLoading, setPaypalLoading] = useState(false)Step 3 — Load PayPal config
Where: Inside the Payment component, alongside your other useEffect hooks.
This fetches PayPal settings from your backend whenever the payment step is opened, so the UI always reflects the latest admin configuration.
useEffect(() => {
if (!isOpen) return
const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
if (!backendUrl) return
const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
const controller = new AbortController()
const loadPayPalConfig = async () => {
try {
const response = await fetch(`${backendUrl}/store/paypal/config`, {
headers: key ? { "x-publishable-api-key": key } : {},
signal: controller.signal,
})
if (response.status === 403) {
setPaypalEnabled(false)
setCardEnabled(false)
return
}
if (!response.ok) return
const config = await response.json()
if (typeof config?.paypal_enabled === "boolean") setPaypalEnabled(config.paypal_enabled)
if (typeof config?.paypal_title === "string" && config.paypal_title) setPaypalTitle(config.paypal_title)
if (typeof config?.card_enabled === "boolean") setCardEnabled(config.card_enabled)
if (typeof config?.card_title === "string" && config.card_title) setCardTitle(config.card_title)
} catch (err) {
if ((err as Error).name !== "AbortError") setPaypalLoading(false)
}
}
void loadPayPalConfig()
return () => controller.abort()
}, [isOpen])Step 4 — Update setPaymentMethod
Where: Inside the Payment component. Find your existing setPaymentMethod function and replace it entirely with the version below.
The key addition is paypalLoading — it shows a loading indicator while the PayPal payment session is being created in the background.
const setPaymentMethod = async (method: string) => {
setError(null)
setSelectedPaymentMethod(method)
if (!isStripeLike(method) && !isPayPal(method)) return
if (isPayPal(method)) setPaypalLoading(true)
try {
await initiatePaymentSession(cart, { provider_id: method })
} finally {
if (isPayPal(method)) setPaypalLoading(false)
}
}Step 5 — Filter the payment method list
Where: Inside the Payment component, alongside your other useMemo declarations — add this before the return statement.
This hides PayPal or Card from the list if they have been disabled in Medusa Admin.
const filteredPaymentMethods = useMemo(
() =>
availablePaymentMethods.filter((paymentMethod) => {
if (paymentMethod.id === PAYPAL_PROVIDER_ID) return paypalEnabled
if (paymentMethod.id === PAYPAL_CARD_PROVIDER_ID) return cardEnabled
return true
}),
[availablePaymentMethods, cardEnabled, paypalEnabled],
)Then in your JSX, find where you render availablePaymentMethods.map(...) and replace availablePaymentMethods with filteredPaymentMethods:
// Before
availablePaymentMethods.map((paymentMethod) => ( ... ))
// After
filteredPaymentMethods.map((paymentMethod) => ( ... ))Step 6 — Inject admin-configured titles
Where: Inside the .map() loop from Step 5, find your <PaymentContainer> component and replace its paymentInfoMap prop with the version below.
This makes the radio button labels show the titles configured in Medusa Admin instead of hardcoded defaults.
<PaymentContainer
paymentInfoMap={{
...paymentInfoMap,
...(paymentMethod.id === PAYPAL_PROVIDER_ID
? { [paymentMethod.id]: { ...(paymentInfoMap[paymentMethod.id] || {}), title: paypalTitle } }
: {}),
...(paymentMethod.id === PAYPAL_CARD_PROVIDER_ID
? { [paymentMethod.id]: { ...(paymentInfoMap[paymentMethod.id] || {}), title: cardTitle } }
: {}),
}}
paymentProviderId={paymentMethod.id}
selectedPaymentOptionId={selectedPaymentMethod}
/>Step 7 — Render the PayPal UI
Where: In the JSX, immediately after the closing </RadioGroup> tag.
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.
{/* Loading state while PayPal session is being created */}
{isPayPal(selectedPaymentMethod) && paypalLoading && (
<div>Setting up payment...</div>
)}
{/* PayPal buttons or card fields */}
{isPayPal(selectedPaymentMethod) && !paypalLoading && (
<MedusaNextPayPalAdapter
cartId={cart.id}
selectedProviderId={selectedPaymentMethod}
baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
onSuccess={async () => {
await placeOrder(cart.id)
}}
onError={(message) => setError(message)}
/>
)}Step 8 — Disable the Continue button
Where: In the JSX, find your existing <Button> with data-testid="submit-payment-button" and add isPayPal(selectedPaymentMethod) to its disabled prop.
PayPal handles its own checkout action, so the "Continue to review" button must be hidden from the flow when PayPal is selected.
<Button
size="large"
className="mt-6"
onClick={handleSubmit}
isLoading={isLoading}
disabled={
(isStripeLike(selectedPaymentMethod) && !cardComplete) ||
(!selectedPaymentMethod && !paidByGiftcard) ||
isPayPal(selectedPaymentMethod) // 👈 add this line
}
data-testid="submit-payment-button"
>
{!activeSession && isStripeLike(selectedPaymentMethod)
? "Enter card details"
: "Continue to review"}
</Button>Step 9 — Fix the summary label
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.
This shows the admin-configured title instead of a hardcoded or missing label.
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
{activeSession?.provider_id === "pp_paypal_paypal"
? paypalTitle
: activeSession?.provider_id === "pp_paypal_card_paypal_card"
? cardTitle
: paymentInfoMap[activeSession?.provider_id]?.title ||
activeSession?.provider_id}
</Text>📄 Complete File
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:
"use client"
import { RadioGroup } from "@headlessui/react"
import { initiatePaymentSession, placeOrder } from "@lib/data/cart"
import { isStripeLike, paymentInfoMap } from "@lib/constants"
import { MedusaNextPayPalAdapter } from "@easypayment/medusa-paypal-ui"
import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
import ErrorMessage from "@modules/checkout/components/error-message"
import PaymentContainer, {
StripeCardContainer,
} from "@modules/checkout/components/payment-container"
import Divider from "@modules/common/components/divider"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
const PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
const PAYPAL_PROVIDER_IDS = [PAYPAL_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID]
const isPayPal = (id: string) => PAYPAL_PROVIDER_IDS.includes(id)
const Payment = ({
cart,
availablePaymentMethods,
}: {
cart: any
availablePaymentMethods: any[]
}) => {
const activeSession = cart.payment_collection?.payment_sessions?.find(
(paymentSession: any) => paymentSession.status === "pending",
)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [cardBrand, setCardBrand] = useState<string | null>(null)
const [cardComplete, setCardComplete] = useState(false)
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
activeSession?.provider_id ?? "",
)
const [paypalEnabled, setPaypalEnabled] = useState(true)
const [paypalTitle, setPaypalTitle] = useState("PayPal")
const [cardEnabled, setCardEnabled] = useState(true)
const [cardTitle, setCardTitle] = useState("Credit or Debit Card")
const [paypalLoading, setPaypalLoading] = useState(false)
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const isOpen = searchParams.get("step") === "payment"
const filteredPaymentMethods = useMemo(
() =>
availablePaymentMethods.filter((paymentMethod) => {
if (paymentMethod.id === PAYPAL_PROVIDER_ID) return paypalEnabled
if (paymentMethod.id === PAYPAL_CARD_PROVIDER_ID) return cardEnabled
return true
}),
[availablePaymentMethods, cardEnabled, paypalEnabled],
)
const setPaymentMethod = async (method: string) => {
setError(null)
setSelectedPaymentMethod(method)
if (!isStripeLike(method) && !isPayPal(method)) return
if (isPayPal(method)) setPaypalLoading(true)
try {
await initiatePaymentSession(cart, { provider_id: method })
} finally {
if (isPayPal(method)) setPaypalLoading(false)
}
}
const paidByGiftcard =
cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
const paymentReady =
(activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams)
params.set(name, value)
return params.toString()
},
[searchParams],
)
const handleEdit = () => {
router.push(pathname + "?" + createQueryString("step", "payment"), {
scroll: false,
})
}
const handleSubmit = async () => {
setIsLoading(true)
try {
const shouldInputCard =
isStripeLike(selectedPaymentMethod) && !activeSession
const checkActiveSession =
activeSession?.provider_id === selectedPaymentMethod
if (!checkActiveSession) {
await initiatePaymentSession(cart, {
provider_id: selectedPaymentMethod,
})
}
if (!shouldInputCard) {
return router.push(
pathname + "?" + createQueryString("step", "review"),
{ scroll: false },
)
}
} catch (err: any) {
setError(err.message)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setError(null)
}, [isOpen])
useEffect(() => {
if (!isOpen) return
const backendUrl = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
if (!backendUrl) return
const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
const controller = new AbortController()
const loadPayPalConfig = async () => {
try {
const response = await fetch(`${backendUrl}/store/paypal/config`, {
headers: key ? { "x-publishable-api-key": key } : {},
signal: controller.signal,
})
if (response.status === 403) {
setPaypalEnabled(false)
setCardEnabled(false)
return
}
if (!response.ok) return
const config = await response.json()
if (typeof config?.paypal_enabled === "boolean") setPaypalEnabled(config.paypal_enabled)
if (typeof config?.paypal_title === "string" && config.paypal_title) setPaypalTitle(config.paypal_title)
if (typeof config?.card_enabled === "boolean") setCardEnabled(config.card_enabled)
if (typeof config?.card_title === "string" && config.card_title) setCardTitle(config.card_title)
} catch (err) {
if ((err as Error).name !== "AbortError") setPaypalLoading(false)
}
}
void loadPayPalConfig()
return () => controller.abort()
}, [isOpen])
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className={clx(
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
{
"opacity-50 pointer-events-none select-none":
!isOpen && !paymentReady,
},
)}
>
Payment
{!isOpen && paymentReady && <CheckCircleSolid />}
</Heading>
{!isOpen && paymentReady && (
<Text>
<button
onClick={handleEdit}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="edit-payment-button"
>
Edit
</button>
</Text>
)}
</div>
<div>
<div className={isOpen ? "block" : "hidden"}>
{!paidByGiftcard &&
filteredPaymentMethods.length > 0 &&
(paypalEnabled ||
cardEnabled ||
availablePaymentMethods.some((method) => !isPayPal(method.id))) && (
<>
<RadioGroup
value={selectedPaymentMethod}
onChange={(value: string) => setPaymentMethod(value)}
>
{filteredPaymentMethods.map((paymentMethod) => (
<div key={paymentMethod.id}>
{isStripeLike(paymentMethod.id) ? (
<StripeCardContainer
paymentProviderId={paymentMethod.id}
selectedPaymentOptionId={selectedPaymentMethod}
paymentInfoMap={paymentInfoMap}
setCardBrand={setCardBrand}
setError={setError}
setCardComplete={setCardComplete}
/>
) : (
<PaymentContainer
paymentInfoMap={{
...paymentInfoMap,
...(paymentMethod.id === PAYPAL_PROVIDER_ID
? {
[paymentMethod.id]: {
...(paymentInfoMap[paymentMethod.id] || {}),
title: paypalTitle,
},
}
: {}),
...(paymentMethod.id === PAYPAL_CARD_PROVIDER_ID
? {
[paymentMethod.id]: {
...(paymentInfoMap[paymentMethod.id] || {}),
title: cardTitle,
},
}
: {}),
}}
paymentProviderId={paymentMethod.id}
selectedPaymentOptionId={selectedPaymentMethod}
/>
)}
</div>
))}
</RadioGroup>
{isPayPal(selectedPaymentMethod) && paypalLoading && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 16px",
marginTop: 8,
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
}}
>
<style>{`@keyframes _idx_spin{to{transform:rotate(360deg)}}`}</style>
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
border: "2.5px solid #e5e7eb",
borderTopColor: "#0070ba",
animation: "_idx_spin .7s linear infinite",
flexShrink: 0,
}}
/>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "#111827",
}}
>
Setting up payment...
</div>
</div>
)}
{isPayPal(selectedPaymentMethod) && !paypalLoading && (
<MedusaNextPayPalAdapter
cartId={cart.id}
selectedProviderId={selectedPaymentMethod}
baseUrl={process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!}
publishableApiKey={process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY}
onSuccess={async () => {
await placeOrder(cart.id)
}}
onError={(message) => setError(message)}
/>
)}
</>
)}
{paidByGiftcard && (
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
Gift card
</Text>
</div>
)}
<ErrorMessage
error={error}
data-testid="payment-method-error-message"
/>
<Button
size="large"
className="mt-6"
onClick={handleSubmit}
isLoading={isLoading}
disabled={
(isStripeLike(selectedPaymentMethod) && !cardComplete) ||
(!selectedPaymentMethod && !paidByGiftcard) ||
isPayPal(selectedPaymentMethod)
}
data-testid="submit-payment-button"
>
{!activeSession && isStripeLike(selectedPaymentMethod)
? "Enter card details"
: "Continue to review"}
</Button>
</div>
<div className={isOpen ? "hidden" : "block"}>
{cart && paymentReady && activeSession ? (
<div className="flex items-start gap-x-1 w-full">
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
{activeSession?.provider_id === "pp_paypal_paypal"
? paypalTitle
: activeSession?.provider_id === "pp_paypal_card_paypal_card"
? cardTitle
: paymentInfoMap[activeSession?.provider_id]?.title ||
activeSession?.provider_id}
</Text>
</div>
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment details
</Text>
<div
className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
data-testid="payment-details-summary"
>
<Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
{paymentInfoMap[selectedPaymentMethod]?.icon || <CreditCard />}
</Container>
<Text>
{isStripeLike(selectedPaymentMethod) && cardBrand
? cardBrand
: "Another step will appear"}
</Text>
</div>
</div>
</div>
) : paidByGiftcard ? (
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
Gift card
</Text>
</div>
) : null}
</div>
</div>
<Divider className="mt-8" />
</div>
)
}
export default Payment🧪 Testing
Toggle between sandbox and live in Medusa Admin → Settings → PayPal → PayPal Connection → Environment.
Sandbox buyer account — log in at developer.paypal.com → Testing → Sandbox Accounts to find your auto-generated buyer credentials. Sandbox payments do not charge real money.
Test card for Advanced Card Fields:
Card number 4111 1111 1111 1111
Expiry Any future date
CVV Any 3 digits📄 License
MIT © Easy Payment
