Blocks

Checkout Form

A one-page checkout — contact, delivery address, payment method and a summary whose tax, delivery and fees are derived from the form in the same render, so the money can never lag the address.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, CircleAlert, CircleCheck, CreditCard, FileText, LoaderCircle, Wallet } from "lucide-react"
import { z } from "zod"

import { cn } from "@/lib/utils"
import {
  type CardBrandId,
  detectCardBrand,
  isLuhnValid,
} from "@/registry/ui/credit-card-form"

/* -------------------------------------------------------------------------- */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/checkout-form.json

Prompt

Build a React + TypeScript + Tailwind "CheckoutForm" block (zod, lucide-react; reuses
detectCardBrand / isLuhnValid from a credit-card-form module). One page, not a wizard:
the order summary has to react to the address and to the payment method, and putting
those on separate screens hides exactly the coupling that goes wrong.

Contract
- CheckoutForm props: cart, onSubmit, defaultValues?, countries?, defaultCountry?,
  methods?, quote?, locale = "en-US", now?, disabled?, messages?. forwardRef to the form.
- cart: { currency (ISO 4217), items: { id, name, variant?, quantity, unitAmount }[],
  discount?: { code?, label, amount } }.
- EVERY amount is an integer in the currency's MINOR UNIT (cents for USD, yen for JPY,
  fils for KWD). Never floats, never toFixed(2). Two reasons: 19.99 * 3 is
  59.96999999999999 in binary floating point, and the number of decimals belongs to the
  currency, not the layout — toFixed(2) invents a JPY price with decimal places.
- Formatting: one Intl.NumberFormat(locale, { style: "currency", currency }) per currency;
  take the divisor from format.resolvedOptions().maximumFractionDigits (2 / 0 / 3), never
  a hard-coded 100. Normalise -0 so a zero line prints "$0.00", not "-$0.00". Render a
  discount as format(-amount) so Intl picks the locale's sign convention instead of a
  hand-written minus. Wrap the constructor in try/catch: a bad currency code out of a data
  feed must not blank the checkout.
- countries: one row per destination carrying BOTH the address shape (field order, row
  labels, an optional region rule with an options list, a postcode rule with a regex) AND
  the money (tax { label, rateBps, regionBps?, taxable: "goods" | "all" }, shipping
  { label, flat, freeOver?, remote? { label, pattern, amount } }). One table, because the
  address and the price are answers to the same question.
- quote(input) returns { subtotal, shipping, shippingLabel, fee, feeLabel?, tax, taxLabel,
  discount }. It must NOT return a total. The component computes
  total = subtotal + shipping + fee + tax − discount itself, which is what makes "the lines
  add up to the button" structural instead of a rule to remember.
- onSubmit(payload) returns void or a Promise. payload = { email, shippingAddress, payment
  (a discriminated union on "card" | "wallet" | "invoice"), totals }. Reject to fail;
  attach a fieldErrors object to the rejection to blame specific rows.

Behavior
- Totals are DERIVED DURING RENDER from the form state — never stored in state and
  refreshed from an effect. That is the entire anti-tearing design: the new country and
  the new tax land in the same commit, so no painted frame can show a new address beside
  an old price. If you replace quote with an async service call, do not await it into the
  same state: keep the answer keyed by the address it was computed for and render a
  "calculated at the next step" placeholder whenever the key does not match what is on
  screen. A stale number is worse than no number.
- The postcode moves the price too, not just the country: a remote-area surcharge is
  matched against the trimmed, upper-cased, space-stripped code, and it survives the
  free-delivery threshold because the carrier still bills it.
- Payment method changes both the fields and the money: card mounts number / expiry / CVC /
  name, wallet mounts nothing and relabels the button, pay-by-invoice mounts company plus
  VAT ID and adds a percentage fee row that VAT countries then tax. Switching clears the
  errors written against the old field set.
- Values are scoped to what is on screen on every render (a country with no administrative
  area drops region; leaving "card" drops the card number), so a hidden row can never
  smuggle a stale value into the payload. Switching country keeps a subdivision only if the
  new country's own list contains it, and keeps a postcode only if it still validates.
- Validation is zod ONLY, rebuilt whenever the country or method changes because both
  change what "valid" means. The form carries noValidate so the browser never competes;
  type="email" survives for the keyboard. Every superRefine branch guards its own inputs —
  refinements all run, and one that assumed a well-formed value would throw out of
  safeParse instead of returning an error object.
- Card number: brand-aware grouping and maxLength, Luhn as an error message, caret held by
  "how many digits are behind it" so a mid-string edit does not fling it to the end, and
  Backspace/Delete reach over a separator the component drew itself. Expiry is MM/YY with a
  real "is it in the past" rule against an injectable now.
- Duplicate submits are stopped by a REF read and written synchronously at the top of the
  handler, not by state: five presses inside one tick all read the same stale state and the
  customer is charged five times. The ref stays engaged after a success — a payment that
  went through must not be submittable again.
- The pay button is aria-disabled, never natively disabled: the browser blurs a control the
  instant it becomes disabled, and the button has to stay focusable to take the handover
  when the fields disable themselves mid-flight.
- Failure clears NOTHING — not the address, not the card number. A decline is usually
  retried with the same details. With field errors, focus moves to the first blamed row ON
  THE NEXT COMMIT, because the commit that reports the error is also the commit that
  re-enables the fields, and focus() on a still-disabled control fails silently. Verify
  focus landed (document.activeElement === node) instead of assuming it did. With no field
  to blame, focus stays on the pay button, which is aria-describedby the alert banner.
- The full card number never appears anywhere but its own input: no summary line, no live
  region, no title, no data attribute, and no name attribute on any card input, so a stray
  native submit cannot post it. CVC refuses paste (a pasted code was stored somewhere it
  should not have been) while the number field still accepts one. The polite live region
  carries the detected brand and the code length — no digits.
- An empty basket refuses the press and says why, instead of authorising a payment of
  nothing. Quantities and prices are clamped (non-finite to 0, negative or fractional
  quantities to whole and non-negative) and a discount larger than the basket is clamped to
  it, so the discount printed is always the discount subtracted. "Empty" is counted in
  UNITS, not line entries — quote receives itemCount as the sum of the clamped quantities —
  so a basket whose only line has fallen to 0 is empty, and is never quoted delivery, a
  percentage fee, or the tax on top of them.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / bg-muted / text-muted-foreground /
  border / border-input / bg-primary / text-primary-foreground / text-destructive /
  ring-ring / accent-primary. No hex, no rgb(), no oklch().
- Container queries, not viewport media queries: the fields column and the summary sit side
  by side at 52rem with the summary sticky, and stack below it. Address rows go two-up at
  30rem; the region and postcode are the only narrow ones.
- Money cells are tabular-nums and shrink-0; labels get [overflow-wrap:anywhere] with
  min-w-0 so a long product name wraps instead of pushing the page sideways (break-words
  alone does not shrink min-content).
- Each field owns a fixed-height message slot, so an error appearing does not push the pay
  button out from under a cursor already on its way down.
- Every control has a real label element, aria-invalid and aria-describedby pointing at a
  node that exists; the payment picker is native radios in a fieldset; the country and
  region pickers are native select elements (a div listbox cannot be filled by address
  autofill) with explicit option token colours so the dark-mode popup is readable.
- autocomplete has to be exact or the browser's address and card managers are useless:
  email, shipping country / name / address-line1 / address-line2 / address-level2 /
  address-level1 / postal-code, cc-number, cc-exp, cc-csc, cc-name, organization.
- transition-colors with motion-reduce:transition-none, and the pending spinner is
  motion-reduce:animate-none — the "Processing…" label carries the state when animation
  is off.

Customization levers
- Destinations and money: trim or extend countries. Each row is independent — field order,
  row labels, region list, postcode regex, tax rate (with per-region overrides), whether
  tax applies to delivery, flat delivery, free-delivery threshold, remote-area surcharge.
  The built-in rates are illustrative; hand real tax to a tax engine.
- Replace quote outright to price from your own service (see the keyed-answer note above if
  it is asynchronous).
- Ways to pay: pass methods to reorder, drop one, or change a fee's basis points and its
  row label. submitLabel on a method replaces the "Pay $X" button text.
- Currency and locale: the cart's currency decides the decimals, locale decides grouping
  and symbol placement. A 0- or 3-decimal currency needs its own delivery figures in
  countries, since those are written in minor units.
- Copy: messages overrides every string outside the country rules; validation wording lives
  in buildCheckoutSchema, exported so a server route can run the identical check.
- Layout density: the two-column breakpoint, the address two-up breakpoint and the
  summary's sticky offset are single classes. Add a thumbnail column to the line items, or
  a phone row with autocomplete `shipping tel`, without touching the totals path.

Concepts

  • Derived totals — the money is recomputed from the form state during render rather than stored beside it. A country change and its tax land in the same commit, so there is no paintable frame in which the address has moved and the price has not. Tearing only comes back with an asynchronous quote, and the answer to that is to key the quote by the address it was computed for.
  • Minor units — every amount is an integer in the currency's smallest unit, and Intl is asked how many decimals that currency has. This is what keeps subtotal + delivery + fee + tax − discount === total exact, and what stops a JPY order from printing invented decimal places.
  • Structural totalquote returns the parts and is not allowed to return a total; the component derives it. An implementation that accepts both can print lines that do not add up, and no amount of care prevents it.
  • Address-driven price — tax comes from the country and, in the US and Canada, the subdivision; delivery comes from the country plus a postcode-matched remote-area surcharge that survives the free-delivery threshold. The postcode is not decoration.
  • Synchronous duplicate guard — a ref is read and written before anything can yield, so five presses inside one tick see one another. A state flag cannot: all five read the same stale value, and all five charge the card.
  • Deferred error focus — the commit that reports a decline is also the commit that re-enables the fields, so focus is requested and applied on the next commit, and the result is verified rather than assumed. focus() on a disabled control neither throws nor moves anything.

On This Page