Order Confirmation
The page you land on once the money has moved — a dictatable, one-click-copyable order number, an itemised table whose money is refined to add up, a delivery window rather than a promised day, ship / pickup / digital fulfillment, and print styles that survive paper.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/order-confirmation.jsonPrompt
Build a React + TypeScript + Tailwind "OrderConfirmation" block (lucide-react
icons) with zod. It is the one-shot receipt a buyer lands on after checkout —
not a live tracking view.
Contract
- A sibling zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
order?: { number, placedAt, state, currency, email?, lines[], totals,
fulfillment, payment?, completedAt?, cancelledAt?,
cancelReason?, actions } }.
- `status` is the FETCH state of the page; `order.state` is where the parcel
is ("placed" | "processing" | "shipped" | "delivered" | "cancelled").
Two different questions, never collapsed into one enum. `empty` means the
lookup succeeded and there is no such order — an expired link — which owes
the reader a different sentence from "we couldn't reach the service".
- Money is an INTEGER in the currency's minor unit (4900 = $49.00,
128000 = ¥128,000 — the yen has no minor unit). One order settles in
exactly ONE currency: there is a single `currency` field, so the component
can never be asked to add two currencies into one total. Converting would
need a rate and the date it was taken, and neither is in the contract.
- The arithmetic is enforced by the schema, not by a comment:
line.amount === line.quantity * line.unitAmount;
Σ line.amount === totals.subtotal;
totals.subtotal + shipping + tax − discount === totals.total;
discount <= subtotal + shipping + tax. `discount` is a POSITIVE magnitude
that is subtracted. Per-line promotions therefore live in the order-level
discount, never in a quietly reduced line amount — otherwise the receipt
prints a unit price that multiplies out to something else.
- `fulfillment` is a discriminated union on `method`, not a bag of optional
fields: { "ship", address, eta?, carrier?, tracking? } |
{ "pickup", location, readyWindow?, instructions? } |
{ "digital", email, access? }. Modelling it as `address?` is what lets a
download render an empty "Shipping to" card.
The schema also refines: a pickup or digital order cannot carry a shipping
charge, and the lifecycle timestamps agree with the state in BOTH
directions (delivered <=> completedAt, cancelled <=> cancelledAt).
- Every href is refined to be an absolute http(s) URL or a root-relative
path; "" and "#" are rejected. `actions.invoice` is three-way — omitted /
{ pending, reason } / { ready, label, href } — so a document that is still
being generated prints its reason as text instead of a button that does
nothing.
- Every timestamp is an absolute instant (ISO 8601 with Z or a numeric
offset). Props add locale ("en-US"), timeZone ("UTC"), now?, heading?,
groupSize (4), onRetry?, onPrint?, className, and the rest spread onto the
root section; forwardRef to that section.
Behavior
- Four first-class branches: loading (skeleton mirroring the header, three
item rows and the totals block, with motion-reduce:animate-none), empty,
error (with "Try again" only when onRetry exists), ready. "ready" with no
order degrades to the empty panel rather than a receipt full of blanks.
- Order number. It must survive being read down a phone line AND pasted into
a support ticket, so it is chunked visually — but the separator is a
MARGIN, not a character. Nothing is inserted into the text, so selecting
the number by hand yields exactly the stored value, the same string the
copy button writes. A number that already carries punctuation
("MUC-2026-0042") is left alone; re-chunking a structured value makes it
harder to read. Render it in an output element labelled by its own dt.
- Copy has a real degradation path. When the clipboard API is missing or
denied, the promise resolves false: put the number under the caret with a
Range/Selection and say so ("the number is selected, press your copy
shortcut"). Never report success you did not get — and note that the
"Copied" flag lingers for a couple of seconds, so a SECOND attempt that
fails inside that window has to outrank it. Deriving the label from the
hook's flag alone leaves a failed copy showing "Copied".
- Announce once. A role="status" region carries the success (or cancellation)
headline and NOTHING inside it ever mutates — a live region re-announces on
any descendant change, so a copy button living there would re-read the
whole confirmation every time someone copied the number. Copy feedback gets
its own small polite region outside it. The output element is aria-live
"off" for the same reason: the number does not change, and the banner owns
the single announcement.
- Delivery is a WINDOW, never a day: format both ends with
Intl.DateTimeFormat.formatRange, which collapses the shared parts
("Aug 4 – 6, 2026") and knows where the dash goes in the target locale.
Rendering one exact date turns an estimate into a guarantee the seller
never made. Feed the window as the instants that bound the promised days IN
THE DELIVERY ZONE: "2026-08-04T00:00:00Z" is still August 3 in Portland, so
a naive midnight-UTC window prints one day earlier than the carrier said.
- Relative phrasing ("In 2–4 days", "Arriving today", "The estimate has
passed") is computed from an INJECTED `now`, never Date.now(), so a server
render and a client render agree and the demo is reproducible. Omit `now`
and the dates simply print with no relative line — nothing is invented.
Day arithmetic goes through Intl.DateTimeFormat.formatToParts in the
explicit timeZone: 2026-08-04T23:30Z is Aug 4 in London and Aug 5 in Tokyo,
so getDate() on the visitor's clock gives two different answers to one
carrier promise. The panel says which zone it is reading in, and that the
window is an estimate.
- Lifecycle drives the whole header: delivered replaces the estimate with the
date it actually arrived (a stale eta in the payload is ignored), and
cancelled swaps the tick for a destructive icon, drops the estimate and
prints its reason. The money still has to add up in both.
- Fulfillment shapes what exists at all: a digital order renders NO address
block and NO shipping row; a pickup order renders the shipping row as an
explicit zero with its reason beside it ("Store pickup") — silence there
reads as "we forgot to add it". A shipped order with zero shipping says
"Free".
- Print is a first-class medium here, because this page gets printed. The
rule is: paper keeps text that IS data and drops text that is only a
control. print:hidden goes on the actions footer, the copy button, the copy
feedback and the "open your library" link (a verb phrase with no URL behind
it); the numbers, the address and the tracking code stay, because a
tracking code is data that happens to be wrapped in an anchor. Item rows,
the totals block, the header and the fulfillment card all carry
break-inside-avoid so a row is never sliced in half by a page break.
Rendering & styling
- The itemised list is a real table (th scope="col" headers, the product name
as th scope="row", named by aria-labelledby pointing at the visible item
count). The money breakdown is a real dl with one dd per figure. Nothing
inside the table is sr-only and no cell holds a block-level child: hidden
helper text and stray line breaks both come back out when the table is
selected and pasted into a spreadsheet.
- Amounts are right-aligned, tabular-nums, whitespace-nowrap — a column of
money is read by comparing digit positions. Discounts are rendered by
passing a negative number to Intl, never by prefixing a minus sign.
- Product names, addresses, emails and payment labels use wrap-anywhere
together with min-w-0. break-words does NOT lower a column's min-content
width, so an unbroken 40-character SKU still widens the table; only
`anywhere` does, and only when the flex/grid child is allowed to shrink.
- Semantic tokens only: bg-card, bg-muted/40, border, text-muted-foreground,
primary for the confirmed treatment, destructive for cancelled and for a
window that has already passed. No hard-coded colours or radii.
Customization levers
- Sections: drop the fulfillment column for a digital-only shop, or the
payment/meta entries you do not store. The grid is
lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] — make it one column for a
narrow drawer, or swap the sides.
- Order number: `groupSize` sets the chunk length (0 or 1 disables grouping);
the grouping rule itself is one small function you can replace with your
own house format.
- Lifecycle: STATE_TITLE is one Record at the top of the file — rename the
states or add "backordered" there plus a branch for its icon.
- Money policy: change which rows are always shown (tax is unconditional
here, discount only when non-zero); a shop with per-line discounts should
extend the line schema and relax the amount refinement deliberately, not
by accident.
- Localisation: locale, timeZone and now are props. Pass the customer's own
zone instead of UTC, and dateStyle "long" for a wordier estimate.
- Print: the print:hidden set and the break-inside-avoid set are the two
knobs. Add print:hidden to the fulfillment card if your packing slip
already carries the address.
- Actions: viewOrder and continueShopping are required, invoice is optional —
add a "Start a return" action the same way, as a labelled href refined
against dead links.
- Hooks for the host: data-slot ("order-confirmation", "order-number",
"copy-order-number", "copy-feedback", "line-items", "line-item", "totals",
"fulfillment", "shipping-address", "pickup-address", "eta", "actions") plus
data-state, data-method and data-total are stable query/style handles — use
them from your own CSS or tests instead of matching on utility classes.Concepts
- Grouped by margin, not by characters — the number is chunked into readable blocks with spacing, so nothing extra enters the text. The copy button writes the stored value, and so does a manual selection; a literal hyphen would have made those two disagree, and the one pasted into a support ticket would be the wrong one.
- Copy has a floor — clipboard writes fail (insecure context, denied permission, an old browser). When the promise resolves false the component puts the number under the caret with a
Rangeand tells the reader to press their copy shortcut, instead of flashing "Copied" at someone whose clipboard is empty. - One announcement, and nothing in it moves —
role="status"re-announces on any descendant mutation, so the copy button lives outside it. Put a toggling "Copied" label inside the success region and every copy re-reads the entire confirmation to a screen-reader user. - A window, not a date — carriers promise a range, so the page prints a range through
Intl.DateTimeFormat.formatRange. Collapsing it to one day converts an estimate into a commitment the seller never made, and every day of slack becomes a support ticket. - Injected
now, explicit zone — "in 2–4 days" is computed from anowprop and day arithmetic done in the namedtimeZone.2026-08-04T23:30Zis August 4 in London and August 5 in Tokyo; reading days off the visitor's clock gives two answers to one carrier promise, and makes the server and client renders disagree. - Fulfillment decides what exists — a discriminated union, so a download has no address block to leave blank and no shipping row to show, and a pickup shows its zero delivery charge with the reason. An optional
addressfield is what produces the empty grey card. - The receipt has to add up — the schema refines the arithmetic (
Σ line.amount === subtotal,subtotal + shipping + tax − discount === total), so a payload whose total forgot the tax fails validation at the data layer instead of printing a plausible lie the customer will check by hand. - Print is a target, not an afterthought — this page gets printed and filed. Controls that cannot act on paper are hidden, rows and the totals block carry
break-inside-avoid, and the tracking code stays visible because on paper it is text, not a link.
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.
Feature Comparison
A dated us-vs-competitors capability table — yes / partial / no / not-disclosed told apart by shape and word, numeric rows with a per-row unit, a frozen capability column, and a verified-on date measured against an injected now.