Checkout UX
The shipped checkout already follows these rules. That means React’s
<Checkout> and the <openreceive-checkout> custom element. Read this page if
you build your own UI on @openreceive/browser/headless, or if you are
deciding how much of the shipped checkout to keep.
Each rule names the helper that already does it. Use the helper.
Show a status, not a stepper
Do not draw Cart → Pay → Done. Checkout is a status, not a position on a line.
Render createCheckoutStatusModel. Use its title, detail, countdown, and
phase. That phase already treats a timed-out invoice as expired, so the
screen changes when the clock runs out.
Use checkoutLabels.switchPaymentMethod for the back-link. That is a
breadcrumb, not a step back. The method grid and the Lightning invoice are
separate screens, and only one shows at a time. Switching payment method hides
the QR, countdown, waiting status, and copy action. Keep the live bolt11 in the
session so you can reuse it when the payer selects Bitcoin again. Do not
dismiss it. Only a swap’s refund_required screen forbids leaving.
Show what they are buying
The shipped checkout can only show the amount, because OpenReceive does not own
your line items. Return an optional description from amountFor
(config.amount_for in Rails). Both drop-ins print it above the amount. It is
also the invoice memo, so the payer’s wallet shows the same words. Without it,
the BOLT11 carries no description at all.
It is one display string. For a richer order summary, pass markup:
- React
<Checkout>takeschildren, a render prop that receives the live model. - The custom element projects
slot="order"into its shadow root.
See Frontend checkout.
Ask “which network?” only when there is more than one
USDT has several networks. SOL and ETH have one. For a single-network coin, start the swap from the tile. Do not ask a question that has only one answer.
Call resolveWizardSelection and branch on kind. A one-network group comes
back as start_swap, never choose_network. Key the selection map by group
(USDT), with pay_in_asset as the value (USDT_TRON).
Say why a method is unavailable
A greyed tile that just says “unavailable” does not tell the payer whether to
add a dollar or give up. createMethodGridDisplay puts limitMessage on the
tile (“Minimum amount $2.71”). The limit comes from the group’s cheapest
network.
Give every pasteable value a copy row
The deposit address, the memo (when present), and the deposit amount each get a labelled copy row. On token rails the QR holds only the address, so the payer types the amount by hand.
Use display.copyRows from createSwapDisplayModel. Copy the amount as a
bare number (0.032664), not 0.032664 SOL.
A required memo is part of the address. A deposit sent without it may never be refundable. Put it in a copy row, not inside a warning banner.
One amount to send
A payer on a USDC checkout asked “50.05 or 50.03?”. The header said “Pay 50.05 USDC”. The payment breakdown, one line below, said “You send $50.03”. That second number was the rate feed’s USD value of the first. The payer could not tell which one to type into their wallet.
swap.deposit_amount is the only amount a payer is ever told to send. It is in
the pay-in token. swap.fee.pay_in_fiat and payout_fiat are valuations. They
explain why the deposit is larger than the cart total, and they are not
instructions.
Some stablecoins are pegged to the fee currency. The shared asset table marks
this with pegged_to: USDT and USDC are pegged to USD. For these coins:
- The breakdown is in the token: “You send 50.05 USDC”, “Swap + network fees 1.05 USDC (2.1%)”.
pay_in_fiatis never rendered, neither in the breakdown nor in the transaction details.- The cart total stays in fiat.
Floating assets (SOL, ETH) keep the fiat breakdown. There, “$50.03” cannot be mistaken for “0.71 SOL”.
This is not a depeg rule. On an ordinary day, a feed rate a hundredth of a
percent off $1.00 puts the two numbers a cent apart. So the fiat value of a
pegged deposit is never shown at all. It is not shown behind a threshold or
with an “approx.” marker either. createSwapFeeBreakdown(fee, swap) applies
the rule. Pass the swap, not just the fee, or the breakdown falls back to fiat.
Use the model’s network warning
Render swap.networkWarningTitle and swap.networkWarning exactly as the
model gives them. Some rails get a loud “wrong network = lost funds” warning.
SOL does not, because a Solana address cannot be an Ethereum address. Do not
hard-code one banner for every coin.
Wallet suggestions go under the Lightning invoice
getPaymentWizardRoutes() plus createWizardRouteDisplays returns the rows.
Each row has a name, icon, link, and optional pay tutorial. This is for
Lightning only. Present the rows as suggestions, not endorsements. Any wallet
that pays a Lightning invoice works.
The logos are data URIs inside @openreceive/provider-data, so you have
nothing to serve, under any bundler. A tutorial’s screenshot arrives after
loadPayTutorialImages() resolves. Until then,
WizardProviderTutorialDisplay.image is undefined. Before that, draw the
caption alone. Never draw an <img> with an empty src. See
Provider registry.
On a short panel, pass providerPreviewLimit so the list does not push the
QR off the screen.
No “Open wallet” button on desktop
openWallet leaves the current page. On a phone, that hands off to a wallet
app. On a desktop, it either does nothing or takes the payer away from a
checkout that is still waiting for payment. The drop-in draws no wallet button
unless you pass components.OpenWalletButton.
Use the packaged labels
checkoutLabels holds every payer-facing string the shipped UI prints
(copyInvoice, switchPaymentMethod, chooseNetwork, and the rest). Read
it before you write your own.
Show the transaction record, collapsed
createTransactionDetails({ reference, checkout_id, ...displayInvoice }) builds
the rows. So does createTransactionDetailsFromState(state). The rows are the
order id, amounts, bolt11, payment hash, explorer links, and swap fields. Keep
the panel collapsed. Show it on the live checkout and on the receipt after
payment.
Skip the caret when there are no rows, which happens when nothing has been chosen yet.
This is the payer’s evidence that they paid. Both shipped settled panels render this builder.
The refund screens
A swap refund is a payer reclaiming a deposit that never converted. There is no merchant refund of a settled Lightning payment.
It is two steps, and only the second submits:
await controller.stageSwapRefund({ attemptId, refundAddress });
await controller.confirmSwapRefund({ attemptId, refundAddress });
Validate with getSwapRefundFormError first. Treat 409 as a normal
outcome. The provider state can change between the two steps.
The refund screen replaces the deposit panel. On refund_required,
refund_pending, and refunded, remove the QR, address, amount, and fee
breakdown. On refund_required, also remove “switch payment method” so the
payer cannot dismiss the attempt being refunded.
The payer almost always leaves the page to fetch a refund address. They need a URL that brings them back. Tell the model what you actually built:
createSwapDisplayModel(invoice, { resumable: true });
Then render display.refundReturnLabel. If the page is not bookmarkable, the
label tells the payer not to close the tab. Swap refunds
covers the whole route back. Read it before you turn swaps on.
Related
- Frontend checkout — the drop-ins that already follow these rules
- Headless checkout — the helpers named above
- Swap refunds — the refund flow and the way back
- Provider registry — wallet suggestions and their icons