Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  ArrowRight,
  CheckCircle2,
  Copy,
  Download,
  Mail,
  PackageCheck,
  Printer,
  SearchX,
  Store,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/order-confirmation.json

Prompt

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 Range and tells the reader to press their copy shortcut, instead of flashing "Copied" at someone whose clipboard is empty.
  • One announcement, and nothing in it movesrole="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 a now prop and day arithmetic done in the named timeZone. 2026-08-04T23:30Z is 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 address field 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.

On This Page