npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

npm version License: MIT Medusa v2 Next.js


📋 Table of Contents


📦 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-paypal installed 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.tsx

Open 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.comTesting → 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