The step before payment: a promo code chip, discount and tax lines, a rolling total and a stateful pay button.
Encrypted payment
pnpm dlx shadcn@latest add @latheui/order-summaryPart of Lathe Pro. Set up your project once with your license key: npx latheui init --key <key>. See Lathe Pro.
Order Summary is part of Lathe Pro
The preview is live and free to try. The source, the install and every assembly come with Pro: $79 once, with lifetime updates.
Depends on
Also accepts native section attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| item | OrderItem | a sample order | The product: name, price, and optional description, period and media. Always pass it in production. |
| adjustments | OrderLine[] | [] | Automatic lines: { id, label, amount, note? }. Negative amounts are discounts. |
| promo | { onApply: (code) => PromoResult | Promise<PromoResult>; placeholder? } | — | Enables the promo field. Resolve { ok: true, code, amount, label? } or { ok: false, message }. |
| tax | { label?: string; rate?: number; note?: string } | — | rate is a fraction of the discounted subtotal. Without it, note is shown. |
| currency | string | "USD" | ISO 4217 code. |
| locale | string | "en-US" | Locale for amounts. |
| title | string | "Order summary" | Heading and accessible name. |
| onPay | (totals: OrderTotals) => void | Promise<void> | — | Takes the payment in the page. The button follows the promise it returns. |
| form | { action; method?; fields? } | — | Posts a native form instead, for hosted checkout pages. The total, currency and promo code are posted as hidden fields. |
| payLabel | (formattedTotal: string) => string | "Pay $…" | Text of the pay button. |
| secureNote | string | "Encrypted payment" | Text beside the lock. |
| disabled | boolean | false | Keeps the button visible but blocks payment. |
| footnote | ReactNode | — | Small print under the button. |
Passed to onPay.
| Prop | Type | Default | Description |
|---|---|---|---|
| subtotal* (required) | number | — | The item price. |
| discount* (required) | number | — | Everything taken off, including the promo code. |
| tax* (required) | number | — | Tax on the discounted subtotal. |
| total* (required) | number | — | What to charge, in major units. |
| currency* (required) | string | — | ISO 4217 code. |
| promoCode* (required) | string | null | — | The applied code, to re-check on your server. |
Most providers (Stripe Checkout, Paddle, Lemon Squeezy) redirect to a page they host. Post to a route of yours that creates the session and redirects. Re-compute the amount on the server from the item and promo code; never trust the posted total.
// app/checkout/route.ts creates the session and redirects to it.
<OrderSummary
item={item}
promo={{ onApply: checkPromo }}
form={{ action: "/checkout", fields: { priceId: item.priceId } }}
/>Anything that returns a promise works: a card element's confirm call, a wallet sheet, or a bank link flow such as Plaid Link, wrapped so it resolves on success and rejects on exit.
<OrderSummary
item={item}
onPay={async ({ total, currency, promoCode }) => {
const { clientSecret } = await createPayment({ itemId: item.id, promoCode })
const { error } = await stripe.confirmPayment({ elements, clientSecret, redirect: "if_required" })
if (error) throw new Error(error.message)
}}
/>
// A bank link flow: resolve when the account is linked, reject if the sheet is closed.
const linkBank = () =>
new Promise<void>((resolve, reject) =>
Plaid.create({ token: linkToken, onSuccess: (token) => exchange(token).then(resolve, reject), onExit: () => reject(new Error("Bank link cancelled.")) }).open()
)