@solopress/payments-react
v2.1.0
Published
React helpers for the Solopress Payments API: handles the next_action union once
Readme
@solopress/payments-react
React helpers for the Solopress Payments API. Handles the next_action union once, so every consumer does not reimplement it.
npm install @solopress/payments-react @solopress/payments-clientUsage
Create the intent and the client session on the server, pass the session to the browser, and let the hooks do the rest.
Cards and PayPal are separate: usePayment drives the card form, PayPalButton renders PayPal. Both take the same session and act on the same intent, so a checkout offering the two is just both on the page.
Cards
"use client";
import { usePayment } from "@solopress/payments-react";
import type { ClientSession } from "@solopress/payments-client";
export function PaymentForm({ session }: { session: ClientSession }) {
const { phase, error, cardErrors, payWithCard } = usePayment({
baseUrl: process.env.NEXT_PUBLIC_PAYMENTS_API_URL!,
session,
onSuccess: (intent) => console.log("paid", intent.id),
});
if (phase === "redirecting") return <p>Redirecting you to your bank…</p>;
if (phase === "succeeded") return <p>Payment complete.</p>;
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
payWithCard({
cardholderName: String(data.get("name")),
cardNumber: String(data.get("number")),
expiryDate: String(data.get("expiry")), // MMYY
securityCode: String(data.get("cvv")),
});
}}
>
<input name="name" autoComplete="cc-name" />
<input name="number" autoComplete="cc-number" inputMode="numeric" />
{cardErrors.cardNumber && <span>{cardErrors.cardNumber}</span>}
<input name="expiry" autoComplete="cc-exp" placeholder="MMYY" />
<input name="cvv" autoComplete="cc-csc" inputMode="numeric" />
<button disabled={phase === "processing"}>Pay</button>
{error && <p role="alert">{error.message}</p>}
</form>
);
}PayPal
"use client";
import { PayPalButton } from "@solopress/payments-react";
import type { ClientSession } from "@solopress/payments-client";
export function PayWithPayPal({ session }: { session: ClientSession }) {
return (
<PayPalButton
baseUrl={process.env.NEXT_PUBLIC_PAYMENTS_API_URL!}
session={session}
onSuccess={(intent) => console.log("paid", intent.id)}
fallback={<div style={{ height: 48 }} />}
/>
);
}That is the whole integration. The button is PayPal's own web component, so it looks the way PayPal's branding rules require without you styling it.
| Prop | Default | Notes |
|---|---|---|
| type | pay | Also checkout, buynow, subscribe — changes the wording |
| color | paypal-gold | Also paypal-blue, paypal-white |
| presentationMode | auto | popup, modal or redirect to force one |
| fallback | nothing | Rendered while the SDK loads; reserve the height or the page will jump |
| whenIneligible | nothing | Rendered when PayPal declines to serve this buyer |
| children | — | A function given the hook state, for a status line of your own |
For a button you render yourself, usePayPal returns the same state plus a start() to call from your own click handler. You are then responsible for PayPal's branding requirements.
What happens
- The buyer presses the button. The order is opened through this service, which returns an order id.
- PayPal takes over in a popup, a modal or a redirect.
- On approval the order is captured, again through this service.
The capture also happens server-side when PayPal reports the approval, so a buyer who approves and then closes the tab is still charged. The two cannot double charge. It does mean a payment can succeed after the browser has given up on it, which is one more reason fulfilment belongs on the payment.succeeded webhook rather than on anything the browser saw.
Apple Pay
import { ApplePayButton } from "@solopress/payments-react";
<ApplePayButton
baseUrl={process.env.NEXT_PUBLIC_PAYMENTS_API_URL!}
session={session}
label={`Order ${intent.merchant_reference}`}
onSuccess={(intent) => router.push(`/thanks/${intent.id}`)}
fallback={<div style={{ height: 48 }} />}
/>The element is Apple's own, so it meets Apple's branding rules without styling. It renders nothing where Apple Pay cannot work — any non-Apple browser, a Safari with no card in Wallet, or a vendor account without Apple Pay enabled — so whenIneligible is usually left unset and the customer simply sees your other payment methods.
Before this works, the domain showing the button must be registered. Apple validates every merchant session against the top-level page's domain, so it needs registering against the Opayo vendor account in MyOpayo and adding to the service's PAYMENTS_APPLE_PAY_DOMAINS. Inside a cross-origin iframe, register both domains, pass the parent's as domain, and put allow="payment" on the iframe.
To avoid registering anything, redirect to the payments service's hosted checkout at /v1/apple-pay/checkout instead. It runs on an already-registered domain and needs no browser code at all.
useApplePay returns the same state plus a start() for a button you render yourself. Call it synchronously from the click or Safari will refuse to open the sheet.
Google Pay
<GooglePayButton
baseUrl="https://payments-api.example.com"
session={session}
label={`Order ${intent.merchant_reference}`}
onSuccess={(intent) => router.push(`/thanks/${intent.id}`)}
/>There is no domain registration step and no merchant validation endpoint. The client session must include providers.opayo.google_pay, which requires Elavon to enable the wallet, a gateway merchant id on the Opayo row, PAYMENTS_GOOGLE_PAY_MERCHANT_ID on the API, and the admin TUI toggle (g).
What it does for you
- Card tokenisation. The card number goes straight from the browser to Opayo and is exchanged for a token. It never reaches your servers or the payments service, which is what holds PCI scope at SAQ-A/A-EP.
- The 3-D Secure form post. A real form submission to the issuer, not a fetch, because the customer has to see the bank's page. Defaults to a top-level navigation; the customer returns via the intent's
return_url. - The PayPal handoff. Loads the SDK once, checks the buyer is eligible, opens the order on click and captures it on approval — including after a redirect, which it resumes on the way back.
- Device profiling.
collectBrowserInfo()gathers the 3-D Secure 2 profile the issuer uses for risk-based authentication. A complete profile makes a silent approval more likely, so fewer customers face a challenge.
Content-Security-Policy
Tokenisation posts to Opayo from the browser, and the PayPal SDK loads from PayPal:
connect-src 'self' https://sandbox.opayo.eu.elavon.com https://live.opayo.eu.elavon.com
https://www.paypal.com https://www.sandbox.paypal.com;
script-src 'self' https://www.paypal.com https://www.sandbox.paypal.com
https://applepay.cdn-apple.com https://pay.google.com;
frame-src https://www.paypal.com https://www.sandbox.paypal.com;Apple's host is only needed for the <apple-pay-button> element. ApplePaySession itself is built into Safari, and the sheet is drawn by the operating system rather than the page, so nothing else has to be allowed for it.
Drop the sandbox hosts in production. The Opayo host is in session.providers.opayo.api_base, so it can be read rather than hard-coded.
Phases
| Phase | Meaning |
|---|---|
| ready | Waiting for the customer |
| processing | In flight |
| redirecting | The page is being handed to the issuer; render a holding message |
| succeeded | Authorised |
| failed | Declined or errored; error explains |
usePayPal reports its own, because the PayPal flow has a step cards do not:
| Status | Meaning |
|---|---|
| loading | Fetching the SDK and checking eligibility |
| ready | Waiting for the customer to press the button |
| paying | PayPal's window is open, or the order is being captured |
| succeeded | Captured |
| failed | error explains |
| ineligible | PayPal will not serve this buyer; show something else |
Reading the result page
import { readPaymentResult } from "@solopress/payments-react";
const { paymentIntentId, status } = readPaymentResult();Treat this as a hint for what to render. It comes from a query string and can be edited by anyone. Fulfilment belongs to the payment.succeeded webhook on your server, which is verified and cannot be forged.
